AI Phone 360 · Developers
Developer Platform · Beta

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.

You need three things to use the API:
  1. A paid AI Phone 360 organization with an active subscription (BASIC, PRO, or ENTERPRISE).
  2. The Developer Platform add-on enabled on your organization. During the beta this is granted by our team — request access.
  3. 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.

No active subscription or out of credits → the API is blocked. Exactly like your phone calls: if your subscription lapses, or your credit balance reaches $0 and can't auto-recharge, every live API request, webhook delivery, and AI tool call stops with a 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:

PrefixModeBehavior
aip_live_LiveReal data, real side-effects, metered & billed.
aip_test_Test (sandbox)Never bills, never sends real SMS or triggers real side-effects. See Test mode.
Keep keys secret. A key is shown once at creation — store it in a secret manager. Keys are scoped (least-privilege) and can be rotated or revoked instantly. Never embed a live key in client-side / browser / mobile code.

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 actionUnitNotes
API requestper 1,000 requestsBilled in 1,000-request blocks after your monthly free quota.
Webhook deliveryper delivery attemptEvery attempt counts (including retries) so you can rely on at-least-once delivery.
Custom-tool invocationper callEach time the AI calls one of your endpoints (voice or chat).
SMS sent via APIper segmentBilled 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.

Test-mode is always free. Requests made with an 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.

ScopeGrants
read:callsList & read calls.
read:transcriptsRead call transcripts.
read:recordingsRead call recording URLs.
read:contactsList & read contacts.
write:contactsCreate & update contacts.
write:smsSend SMS via POST /messages.
read:usageRead config & list your keys.
manage:configUpdate AI config; create/revoke keys; define contact custom fields.
manage:webhooksManage webhook endpoints & deliveries.
manage:toolsManage custom tools.
manage:agentsManage specialized AI agents.
read:conversationsList & read SMS / live-chat conversations, their messages and internal notes, the human-handoff queue, and team members.
write:conversationsReply 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:emailSend email replies on email conversations (together with write:conversations).
manage:mailboxesAdd / 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 /messages and POST /conversations/{id}/reply return 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 } }
CodeStatusMeaning
API_KEY_REQUIRED401No key supplied.
INVALID_API_KEY401Key not found, revoked, or expired.
PLATFORM_ADDON_REQUIRED402Developer add-on not enabled for this org.
SUBSCRIPTION_REQUIRED402No active paid subscription.
INSUFFICIENT_CREDITS402Wallet is out of credits — add funds to resume.
INSUFFICIENT_SCOPE403Key lacks a required scope.
BUSINESS_SUSPENDED403Organization suspended for abuse/compliance.
RESOURCE_NOT_FOUND404The id doesn't exist (or isn't yours).
VALIDATION_ERROR400Request body/query failed validation. Live-chat reply bodies must contain 1–2,000 characters.
RATE_LIMIT_EXCEEDED429Too many requests — back off (honor Retry-After).
CONVERSATION_NOT_FOUND / CONTACT_NOT_FOUND / CALL_NOT_FOUND404The id doesn't exist (or isn't yours).
DNC_BLOCKED / CONTENT_BLOCKED / ABUSE_BLOCKED422A send was refused by the compliance gate (recipient opted out, content flagged, org suspended).
CHANNEL_NOT_SUPPORTED / CHANNEL_MISMATCH422The reply channel isn't available for this conversation (e.g. channel: "email" on an SMS thread).
CONVERSATION_CLOSED409The live-chat session is closed.
NO_RECIPIENT422The live chat has no visitor, or the conversation has no usable recipient.
COMM_SERVICE_UNAVAILABLE502The 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_UNAVAILABLE503The chat service is unavailable. Inspect the thread before retrying with a new idempotency key because delivery may be unconfirmed.
EMAIL_RECIPIENT_SUPPRESSED422The recipient hard-bounced or reported your mail as spam; email to them is blocked (the email equivalent of an SMS STOP).
REPLY_TO_AUTOMATED_MESSAGE422The latest message on the thread was an auto-reply or system mail. Pass replyToAutomated: true to reply anyway.
EMAIL_REPLY_RATE_LIMITED429More than 20 email replies on one conversation in an hour (mail-loop backstop).
DOMAIN_NOT_VERIFIED / MAILBOX_UNAVAILABLE422The thread's mailbox is on a domain that isn't verified, or the mailbox was disabled / deleted.
EMAIL_PROVIDER_UNAVAILABLE502The email provider didn't answer; nothing was sent. Safe to retry with a new Idempotency-Key.
DOMAIN_NOT_ALLOWED / DOMAIN_ALREADY_REGISTERED / DOMAIN_HAS_MAILBOXES422 / 409 / 409Shared mail domains (gmail.com…) can't be added; the domain is already registered; delete its mailboxes first.
DOMAIN_NOT_REGISTERED / ADDRESS_IN_USE422 / 409Add the mailbox's domain first; the address already exists.
ATTACHMENT_NOT_FOUND / ATTACHMENT_EXPIRED404 / 410No such attachment on that message (or not yours); the provider no longer has the file.
INVALID_FROM_NUMBER / NO_SENDER_NUMBER422from isn't one of your active numbers / you have no active number.
INVALID_IDEMPOTENCY_KEY400The Idempotency-Key header is malformed.
IDEMPOTENCY_KEY_REUSED422The key was already used for a different request.
IDEMPOTENCY_REQUEST_IN_PROGRESS409The first request with this key is still running — retry shortly.
UNKNOWN_FIELD / INVALID_FIELD_VALUE / RESERVED_FIELD422Custom-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 an aip_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 502 may 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

GET/calls

List calls (newest first). Scope read:calls. Query: limit, cursor, direction, dateRange, search.

GET/calls/{callSessionId}

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).

GET/calls/{callSessionId}/transcript

The call transcript (speaker-attributed turns). Scope read:transcripts.

GET/calls/{callSessionId}/recording

A recording URL (when available). Scope read:recordings.

Contacts

GET/contacts

List contacts. Scope read:contacts. Query: limit, cursor, search.

GET/contacts/{id}

One contact. Scope read:contacts.

POST/contacts

Create a contact. Scope write:contacts. Body: phoneNumber (required), name, email, company.

PATCH/contacts/{id}

Update a contact. Scope write:contacts.

GET/contacts/lookup

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.

GET/contacts/{id}/history

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.

GET/contacts/fields

List field definitions. Scope read:contacts.

POST/contacts/fields

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).

GET/contacts/{id}/custom-fields
PATCH/contacts/{id}/custom-fields

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)

POST/messages

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.

TCPA / DNC is enforced server-side. A send to a number that opted out (replied STOP), or that you have no established relationship with, is blocked with a 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.

GET/conversations

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.

GET/conversations/{id}

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.

POST/conversations/{id}/reply

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. body up 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 (422 when blocked).
  • Email — scopes write:conversations + write:email. body (plain text, up to 50,000 characters), optional html (sanitized; default = body as HTML), optional replyToAutomated, cc (up to 10 bare email addresses) and replyTo (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 emits message.failed with recipient and recipientType: "cc". Verified copied participants can reply on the same thread. Both connected SMTP mailboxes and the email provider support these fields. Partial SMTP acceptance returns rejectedRecipients. If the primary recipient refuses the message, sent is 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), with Re: subject and In-Reply-To / References headers so it threads in the customer's mail app. It carries Auto-Submitted: auto-replied so the far side's autoresponders stay quiet. Blocked with 422 for suppressed recipients (bounced / spam-reported), unverified domains and disabled mailboxes; refused on a thread whose latest message is an auto-reply unless replyToAutomated: 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 return 409 CONVERSATION_CLOSED. A missing comms endpoint or a service-authentication failure returns 502 COMM_SERVICE_UNAVAILABLE; deploy the comms reply endpoint before the backend route. Test keys return synthetic success without saving, changing mode or delivering anything.
POST/conversations/{id}/notes

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.

POST/conversations/{id}/assign

Body: { "teamMemberId": "…" } (from GET /team-members) or { "teamMemberId": null } to unassign. Scope write:conversations. Fires conversation.assigned when the assignee changes.

POST/conversations/{id}/status

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.

POST/conversations/{id}/tags

Body: { "add": ["Order issue"], "remove": ["VIP"] } — tag names, matched case-insensitively; new names are created for you. Scope write:conversations.

GET/conversations/{id}/messages/{messageId}/attachments/{attachmentId}

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.

GET/team-members

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:

POST/conversations/{id}/handoff

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.

DELETE/conversations/{id}/handoff

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

POST/email/domains

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:

RecordTypeWhat it does
DKIM — resend._domainkeyTXTSigns your mail so receivers can verify it came from you.
SPF — sendMX + TXTAuthorizes our sending servers on a return-path subdomain, so it never conflicts with an SPF record you already have on the apex.
Receiving — inbound MXMXOnly when receiving: true. Routes the domain's incoming mail to AI Phone 360.
Already receive mail on this domain? (Google Workspace, Microsoft 365…) Don't add the inbound MX to that domain — mail is delivered to the lowest-priority MX only, so you would stop getting it in your current inbox. Use a subdomain instead (e.g. 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.

POST/email/domains/{id}/verify

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.

GET/email/domains
GET/email/domains/{id}
DELETE/email/domains/{id}

Delete is refused (409 DOMAIN_HAS_MAILBOXES) while mailboxes use the domain.

2. Create mailboxes

POST/email/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).

GET/email/mailboxes
PATCH/email/mailboxes/{id}
DELETE/email/mailboxes/{id}

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 / References headers — only when the sender is already part of that thread; otherwise by the same mailbox + same customer address + same subject (ignoring Re:, 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 with autoSubmitted: true but 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_failed or unknown, computed by our receiving server, so a sender cannot forge them) and senderVerified, which is true only 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) when senderVerified is false. Outbound messages have senderVerified: null.
  • Size limits: plain text up to 100,000 characters (bodyTruncated: true beyond 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)

POST/email/mailboxes/test-connection
{
  "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

POST/email/mailboxes

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).

GET/email/mailboxes/{id}
PATCH/email/mailboxes/{id}
POST/email/mailboxes/{id}/test-connection

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.

statusMeaningWhat happens
connectedThe last sync or test worked.INBOX is checked about every 2 minutes. Replies send.
unreachableThe 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_failedThe server rejected the login.Retried every 30 minutes. Replies are refused (422 MAILBOX_NOT_CONNECTED).
disableddisabledReason: "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

ProviderIMAPSMTPPassword
SiteGroundyour server host (Site Tools → Email → Accounts → Mail Configuration, e.g. gvam1234.siteground.biz), 993 TLSsame host, 465 TLSThe mailbox password.
Bluehostmail.yourdomain.com or the server host shown under Email → Connect Devices, 993 TLSsame host, 465 TLSThe mailbox password.
Google Workspace / Gmailimap.gmail.com, 993 TLSsmtp.gmail.com, 465 TLS or 587 STARTTLSAn 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.comoutlook.office365.com, 993 TLSsmtp.office365.com, 587 STARTTLSNot 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 sentCopy to 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 returns 409 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.

Check your server id. Open any message in the mailbox, view its original headers, and look at the first 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

CodeHTTPMeaning
MAILBOX_CONNECTION_FAILED422Create or update: the IMAP login or SMTP authentication failed. The message says which and why.
MAIL_HOST_NOT_ALLOWED / MAIL_HOST_NOT_FOUND422The 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_ALLOWED400Port or security mode outside the allowed list.
MAIL_USERNAME_DOMAIN_MISMATCH422The login is on a different domain than the address.
MAIL_PASSWORD_REQUIRED400A PATCH moved IMAP or SMTP to a different host without the password.
MAILBOX_CONNECTION_RATE_LIMITED429More than 10 connection attempts (tests, creates, credential changes) in an hour.
MAILBOX_LIMIT_REACHED422More than 10 connected mailboxes on the account.
MAILBOX_NOT_CONNECTED422Reply refused: the mailbox is disabled or its login is failing.
MAILBOX_AUTH_FAILED422Reply refused by the SMTP server's login (counts toward auto-disable).
EMAIL_RECIPIENT_REJECTED422Your SMTP server refused the recipient address; it is now suppressed.
LIVE_KEY_REQUIRED422Connected 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

GET/config

Read a curated subset of your AI configuration. Scope read:usage.

PATCH/config

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.

GET/webhooks
POST/webhooks

Register an endpoint. Body: url (public HTTPS), events (array). Returns a whsec_… signing secret — store it to verify deliveries.

PATCH/webhooks/{id}
DELETE/webhooks/{id}
POST/webhooks/{id}/test

Send a test delivery to verify your endpoint.

GET/webhooks/{id}/deliveries
POST/webhook-deliveries/{id}/redeliver

Inspect delivery history and replay any delivery.

API keys (manage)

GET/keys

List your keys (masked). Scope read:usage.

POST/keys

Create a key — returns the secret once. Scope manage:config. Body: name, mode (test|live), scopes.

DELETE/keys/{id}
POST/keys/{id}/rotate

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.

GET/tools
POST/tools
PATCH/tools/{id}
DELETE/tools/{id}

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).

GET/agents
POST/agents
PATCH/agents/{id}
DELETE/agents/{id}

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

EventFires when
call.startedAn inbound call begins.
call.completedA call ends & post-call processing finishes (incl. summary).
transcript.readyThe transcript is available.
sms.receivedAn inbound text arrives.
sms.sentAn outbound text is sent.
voicemail.createdA voicemail is recorded.
contact.created / contact.updatedA contact is created / meaningfully changed.
appointment.bookedAn appointment is booked.
email.receivedAn 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.sentAn 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.receivedA 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.deliveredThe 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.failedAn 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_changedA conversation's status changed via the API, or a customer texted / emailed into a pending, closed or archived conversation (it reopens).
conversation.assignedA conversation's assignee changed (API, or the live-chat inbox assign action).
conversation.needs_humanAn integration flagged a conversation for a person (POST /conversations/{id}/handoff).
note.createdAn 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.

  1. Register a tool (dashboard or POST /tools) with a name, description, JSON-schema for its inputs, your HTTPS endpointUrl, and optional auth.
  2. During a call, the AI decides to use it and AI Phone 360 POSTs { arguments, callSessionId, tool } to your endpoint.
  3. Your endpoint responds with JSON within ~10 seconds; the AI uses it as data (never as instructions — results can't change call control flow).
Secure by construction. Your endpoint must be public HTTPS (internal/private addresses are rejected). Your auth secret is stored encrypted and never exposed to the AI. The AI never reads card numbers or your secrets. Each invocation is metered.
Make your endpoint fast and resilient. If your endpoint is slow (>10s) or errors, the call degrades gracefully (the AI moves on) — it never hangs the caller. Return concise JSON; large responses are truncated.

Use AI Phone 360 as your inbox

A typical setup for your own AI agent working alongside your team:

  1. Subscribe a webhook to sms.received, chat.received, call.completed, message.failed and conversation.status_changed.
  2. On sms.received (and chat.received, which carries the same conversationId / messageId / body): GET /conversations/{conversationId} for the thread, GET /contacts/lookup?phone=… and GET /contacts/{id}/custom-fields for context (order number, store, serial).
  3. Answer with POST /conversations/{id}/reply and an Idempotency-Key; write anything you learned back with PATCH /contacts/{id}/custom-fields.
  4. When a person is needed: POST /conversations/{id}/handoff with a reason and a suggestedReply. Your team works the queue (needsHuman=true&assignee=none); you hear back via conversation.assigned and the team member's reply.
  5. Leave context for people with POST /conversations/{id}/notes — never sent to the customer.
  6. Live chat: subscribe to chat.received, read the thread, then reply with POST /conversations/{id}/reply and an Idempotency-Key. It requires write:conversations only. 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.
  7. Email works the same way: subscribe to email.received, read the thread with GET /conversations/{conversationId}, and answer with POST /conversations/{id}/reply — it goes out from the mailbox the customer wrote to, with its signature. Skip messages with autoSubmitted: true, and treat senderVerified: false as 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.