AI Outbound Calling API
Queue calls that an AI agent places for you and receive each result at a callback URL.
The AI Outbound Calling API lets an external system (a CRM, scheduler, ERP, or any custom application) queue outbound calls that a Cloudspire AI agent places and conducts. You POST one request per call; the platform dials the number, the AI agent handles the conversation, and (optionally) the result is POSTed back to a callback URL of your choice when the call completes.
Calls route through the same PBX infrastructure as every other call on the platform, so they carry proper caller ID, STIR/SHAKEN attestation, and call recording.
How It Works
- Your system POSTs a call request to the webhook endpoint, authenticated with the campaign's API key.
- The request is validated and added to the campaign's outbound queue.
- The platform attempts to place the call immediately. If concurrency is exhausted or the request arrives outside the campaign's calling schedule, the call stays queued and a worker (running every minute) places it as soon as conditions allow. A request that carries
call_atwaits in the queue until that time instead (see Placing a Call at a Set Time). - The AI agent converses with the called party using the per-call variables you supplied.
- When the call ends and is analyzed, the queue record is updated with the outcome. If you supplied a
webhook_response_url, the result is POSTed there.
Prerequisites
Before your requests will result in placed calls, your Cloudspire administrator must have the following in place. Several of these are the reason a request can be accepted (queued) yet not dial.
| Requirement | Why |
|---|---|
A campaign in active status | The API key belongs to a single campaign. A paused, completed, or archived campaign rejects requests (409). |
| A campaign API key | Authenticates your requests. Issued per campaign; regenerating it invalidates the old key immediately. |
| An AI agent + From DID on the campaign | The agent conducts the call; the From DID is the caller ID. Both are configured on the campaign, not per request; you cannot choose the caller ID per call. |
| An AI Voice Minutes pool on the account | The metered pool that outbound (and inbound) AI voice draws on. Required for any AI call to be placed. |
| The AI Outbound Calling add-on on the account | Enables the outbound system specifically (outbound needs both this AND the AI Voice Minutes pool). Without either, requests are still queued but never dialed (the inline result is skipped_no_plan). |
| Outbound AI calling enabled platform-wide | A global master switch. When off, requests are rejected (503). |
Base URL
https://api.cloudspirevoice.com/
Authentication
Authenticate every request with the campaign's API key as a Bearer token:
Authorization: Bearer {campaign_api_key}
The key maps the request to exactly one campaign, which determines the tenant, AI agent, From DID, schedule, concurrency limits, and retry behavior. There is no separate campaign ID to send; it is implied by the key.
- A missing or malformed
Authorizationheader returns401. - An unrecognized key returns
401. - Keep the key server-side. Regenerating it from the campaign edit form invalidates the previous key at once.
Queue an Outbound Call
Queue a single outbound AI call.
Endpoint
POST https://api.cloudspirevoice.com/agent/campaign-intake.php
Headers
| Header | Value |
|---|---|
Authorization | Bearer {campaign_api_key} (required) |
Content-Type | application/json |
Request Body
{
"to_number": "+17705551234",
"contact_name": "John Doe",
"variables": {
"appointment_date": "March 20th",
"appointment_time": "2:30 PM",
"provider": "Dr. Smith"
},
"external_id": "CRM-12345",
"webhook_response_url": "https://your-system.example.com/cloudspire/callback"
}
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
to_number | string | Yes | The number to call, in E.164 format (+17705551234). Numbers in a digits-only form with a country code are normalized, but E.164 is recommended. Rejected with 400 if missing or unparseable. |
contact_name | string | No | The called party's name, for display in the queue and reports. Truncated to 200 characters. |
variables | object | No | Per-call dynamic variables passed to the AI agent (see Dynamic Variables). Flat key/value JSON. The serialized object must be under 64 KB, else 400. |
external_id | string | No | Your own reference for this call (e.g. a CRM record ID). Stored on the queue record and echoed back in the completion callback so you can reconcile results. Truncated to 255 characters. |
webhook_response_url | string | No | An https:// URL to receive the completion callback. Must be a valid https:// URL or the request is rejected with 400. Up to 500 characters. |
call_at | string | No | When to place the call, as an ISO 8601 date and time with its time zone offset, for example 2026-10-15T15:00:00-04:00 or 2026-10-15T19:00:00Z. Omit it to place the call now. A time without an offset, or a time in the past, is rejected with 400. See Placing a Call at a Set Time. |
Success Response
HTTP 202 Accepted
{
"status": "queued",
"queue_id": 84213,
"campaign_id": 5,
"immediate": "dialing"
}
| Field | Type | Description |
|---|---|---|
status | string | Always "queued" on acceptance. The call has been recorded in the queue. |
queue_id | integer | The unique ID of the queue record. Reference it in support inquiries; it also appears in the completion callback. |
campaign_id | integer | The campaign the call was queued under (derived from your API key). |
immediate | string | The outcome of the one inline placement attempt made during the request (see below). This is a hint, not a final status; queued calls are retried by the per-minute worker. |
call_at | string | Present only when the request carried call_at: the time the call is held until, as the platform read it. Check it against the time you meant. |
202 means accepted, not connected. A 202 confirms the call was queued. Whether it dials immediately depends on concurrency, schedule, and account state, reported in immediate. The authoritative outcome arrives later via the completion callback (or in the campaign's queue view).
The immediate Field
Common values:
| Value | Meaning |
|---|---|
dialing | The call was placed immediately and is ringing. |
scheduled | The request carried call_at, so no call was placed now. It stays queued until that time. |
skipped_concurrency | At the campaign or account concurrency ceiling. Stays queued; the worker places it when a slot frees. |
skipped_schedule | Outside the campaign's calling window. Stays queued; placed when the window reopens. |
skipped_no_plan | The account has no active AI outbound capability. Stays queued but will not dial until enabled. |
skipped_inactive | The campaign is not active. (You would normally get a 409 first; this can occur on a race.) |
retry_scheduled | The dial attempt hit a transient provider error; a retry was scheduled. |
failed | The inline attempt failed terminally (e.g. an invalid From DID configured on the campaign). |
Error Responses
| HTTP | Body error | Cause / Resolution |
|---|---|---|
400 | Invalid JSON request body | The body was not valid JSON. Send a well-formed JSON object with Content-Type: application/json. |
400 | Invalid or missing to_number (must be E.164 format) | to_number is absent or not a dialable number. Use E.164 with a country code. |
400 | webhook_response_url must be a valid https:// URL | The callback URL was present but not a valid https:// URL. Omit it or fix it. |
400 | variables payload too large (max 64KB) | The serialized variables object exceeds 64 KB. Send only what the agent prompt needs. |
400 | call_at must be an ISO 8601 date and time with a time zone offset, ... | call_at is not a date and time with an offset, or names a date that does not exist. Send a form like 2026-10-15T15:00:00-04:00. |
400 | call_at is in the past; omit it to place the call now | The time has already passed. Leave call_at out to call now, or send a later time. |
401 | Missing or invalid Authorization header | No Authorization: Bearer ... header. Add it. |
401 | Invalid API key | The key is not recognized. Confirm it was not rotated. |
409 | Campaign is not active | The campaign is paused/completed/archived. The body includes campaign_status. Resume the campaign. |
409 | Number is blacklisted | to_number is on the tenant's blacklist phonebook. Remove it there to allow calls. |
503 | Outbound AI calling is currently disabled | The platform-wide master switch is off. Retry later or contact your administrator. |
500 | Internal server error | A server-side error. Do not blind-retry: the queue row may already exist and the call may already be placed. Reconcile against the queue view first, then resubmit only if your reference is absent. |
All error bodies are JSON of the form {"error": "..."}. The 409 Campaign is not active response additionally includes campaign_status.
Call Lifecycle & Queue Statuses
Each queued call moves through a series of statuses. You observe the final outcome via the completion callback; the intermediate states are visible in the campaign's queue view in the admin portal.
| Status | Meaning | Terminal? |
|---|---|---|
queued | Accepted and awaiting placement (concurrency/schedule/plan permitting). A request with call_at waits here until that time, and retried entries return here until their next attempt time; the queue view shows that time under the status. | No |
dialing | The call has been placed and is ringing / in progress. | No |
completed | The call connected and ended normally. | Yes |
voicemail | Voicemail answered on the final attempt: either the campaign's voicemail message was left, or no message was set to be left and no retry remained. | Yes |
failed | No attempt was answered (no answer, busy, or the dial failed) by the time attempts ran out; the final attempt was answered but nothing was accomplished (a carrier recording, a silent line) or, with Retry When set to Call didn’t finish, the called party cut it short; or the call hit a terminal condition (e.g. a refused destination) or stalled without a result. | Yes |
cancelled | Cancelled from the campaign’s queue view before its next attempt: by hand, with Cancel queued calls, or by pausing the campaign with its Pause button. No callback is sent; the result lookup shows it. | Yes |
blacklisted | The number was found on the tenant blacklist at placement time. | Yes |
Retry behavior is set on the campaign (max attempts and retry delay). An attempt that is not answered, reaches voicemail (when the campaign retries on voicemail), or is answered but accomplishes nothing re-queues the call for another attempt after the retry delay. With the campaign’s Retry When set to Call didn’t finish, a conversation the called party cut short is retried too. The final attempt is never re-queued: it produces the result, so the result of a request that keeps failing arrives with its last attempt; with 3 attempts 30 minutes apart, about an hour after the first dial. One exception: when the last attempt could not be placed at all (the dial itself failed), the request is marked failed one retry delay later.
Pausing a campaign. A paused campaign refuses new requests (409). Calls already queued wait and dial as soon as the campaign is active again, unless they are cancelled: the Pause button cancels them as it pauses, and a paused campaign’s queue view offers Cancel queued calls. Setting the status to paused on the campaign form keeps them.
Completion Callback
If you include a webhook_response_url on the request, Cloudspire POSTs the call result to that URL once the call reaches a terminal status (completed, voicemail, or failed) and post-call analysis has finished. This is how you get the transcript summary and sentiment back into your system.
Testing the callback before your endpoint exists. Ask Cloudspire support for your agent’s Test receiver address (staff issue it, because it carries the agent’s platform token): an address on this platform that records whatever is posted to it and acts on nothing. Send it as the webhook_response_url on a test call, with any query string you plan to use, and the delivery appears under Deliveries to the test receiver on the agent’s Tools card with the address exactly as it was dialed (credential values redacted), the headers and the body. If the agent also has a custom integration pointed at the same address, the two deliveries for one call show the same call_id. The receiver keeps only the newest deliveries for each agent, so it is for proving the contract, not for collecting results.
Delivery Details
| Property | Value |
|---|---|
| Address | The webhook_response_url exactly as you sent it on that request, query string included. Redirects are not followed, so give the final address. |
| Method | POST |
| Authentication | None added by the platform: the callback carries no custom headers and no signature. To make sure a callback came from us, put a secret token of your own in the query string of each webhook_response_url and check it on arrival. |
| Content-Type | application/json |
| Scheme | https:// only |
| Timeout | 5 seconds (3-second connect timeout) |
| Retries | None. The callback is fire-and-forget, deliver-once. If one is missed, look the result up; have your endpoint respond quickly with a 2xx. |
Callback Payload
{
"queue_id": 84213,
"campaign_id": 5,
"external_id": "CRM-12345",
"to_number": "+17705551234",
"contact_name": "John Doe",
"status": "completed",
"duration_sec": 92,
"seconds_to_answer": 11,
"disconnection_reason": "agent_hangup",
"ai_summary": "Confirmed the appointment for March 20th at 2:30 PM with Dr. Smith.",
"ai_sentiment": "Positive",
"ai_successful": true,
"call_id": "sip1.atl.cloudspirevoice.com-1789678613.22316"
}
One Callback per Request
Each request produces one completion callback, sent when the request reaches its terminal status, however many attempts that took. When an attempt connects, the callback follows that attempt (after its analysis) and carries that attempt’s call_id. When no attempt connects, the callback follows the last attempt, with call_id null and the outcome in status and disconnection_reason. When the final attempt was answered but did not succeed (a carrier recording, a silent line), the callback follows that attempt like any answered call, with its call_id and status failed. Attempts that are retried send nothing, and a request cancelled before its final attempt sends none at all (the result lookup shows it as cancelled). Your external_id is on every callback. In the rare case of a platform restart between sending and recording a callback, the same callback can be sent a second time, so treat queue_id as the de-duplication key.
Callback Fields
| Field | Type | Description |
|---|---|---|
queue_id | integer | The queue record ID returned when you queued the call. |
campaign_id | integer | The campaign that placed the call. |
external_id | string or null | The reference you supplied on the request. Use it to match the result to your record. |
to_number | string | The number that was called, in E.164. |
contact_name | string or null | The contact name you supplied. |
status | string | Terminal queue status: completed, voicemail, or failed. |
duration_sec | integer or null | Call duration in seconds (null if the call never connected). |
seconds_to_answer | integer or null | Seconds from dialing to the call being answered, taken from the carrier’s record of the attempt that produced this result. A phone that rings usually takes ten seconds or more to answer. An answer within two or three seconds, especially with voicemail_reached, usually means the carrier sent the call straight to voicemail without ringing the phone, as some carriers do when their call-screening filters divert a number. Use it to spot a number being diverted rather than a person who did not pick up. Null when that attempt was not answered, and on a request cancelled before its final attempt, which has no final attempt to measure. The carrier record is written shortly after the call ends; if a callback is ever sent before it is written, the field is null in that callback and the result lookup below returns it. |
disconnection_reason | string or null | Why the answered call ended; see Disconnection Reasons. Null when no attempt was answered (no answer, busy, or a failed dial). |
ai_summary | string or null | An AI-generated summary of the conversation. |
call_id | string or null | The per-call identifier of the attempt that produced this result. The same value arrives as call.call_id in any mid-call custom integration request made during the call, so you can join the two. Null when the call never connected. |
ai_sentiment | string or null | Caller sentiment: Positive, Neutral, Negative, or Unknown. |
ai_successful | boolean or null | Whether the agent judged the call's goal met: true, false, or null (not determined, including every call that never connected). |
webhook_response_url, no callback is sent; use the result lookup or the campaign's queue view and reports instead.
Disconnection Reasons
disconnection_reason says how an answered call ended. Read the outcome from status; the reason adds detail, and new values can be added, so handle a value you do not recognize by its status.
| Value | Usual status | Meaning |
|---|---|---|
agent_hangup | completed | The agent finished the conversation and ended the call. |
agent_transfer, agent_handoff | completed | The agent transferred the call to a person, another destination, or another agent. |
agent_voicemail | voicemail | The agent put the called party through to a voicemail box to leave a message. |
caller_hangup, eof | completed, or failed when the final attempt accomplished nothing or was cut short (see the retry rules above) | The called party (or their carrier) ended the call. A short carrier recording that hangs up on its own before the agent can respond ends this way. |
silence_timeout | completed, or failed when nothing was accomplished | The line was answered but stayed silent. |
max_duration_reached | completed | The call reached the agent’s maximum call length. |
voicemail_reached | voicemail | Voicemail answered and no message was left. |
voicemail_drop_played | voicemail | Voicemail answered and the campaign’s voicemail message was left. |
voicemail_drop_truncated | failed | The voicemail message was cut off before it finished, so it counts as not delivered (and is retried while attempts remain). |
unreachable | failed | The agent recognized that the number cannot take the call: a mailbox that is not set up or is full, a disconnected-number recording, or an automated system it cannot get past. |
Other values with status failed | failed | A technical failure on the call (for example read_error or tts_no_audio). Worth reporting to support if you see them repeatedly. |
Looking Up a Result
The callback is sent once and is not retried, so if your endpoint was unreachable when it fired, ask for the result instead. The lookup uses the same campaign API key as the intake, reads only your own campaign’s requests, places no call, and answers whatever the campaign’s status is.
GET https://api.cloudspirevoice.com/agent/campaign-result.php?external_id=CRM-12345
GET https://api.cloudspirevoice.com/agent/campaign-result.php?queue_id=84213
Authorization: Bearer {campaign_api_key}
Pass exactly one of external_id (your reference) or queue_id (the id returned in the 202). The response carries the same fields as the completion callback, plus attempts, created_at, completed_at and terminal (false while the request is still queued, dialing or connected, so you can tell a result from a request in progress):
{
"campaign_id": 5,
"count": 1,
"results": [{
"queue_id": 84213,
"call_id": "sip1.atl.cloudspirevoice.com-1789678613.22316",
"campaign_id": 5,
"external_id": "CRM-12345",
"to_number": "+17705551234",
"contact_name": "John Doe",
"status": "completed",
"terminal": true,
"attempts": 1,
"duration_sec": 92,
"seconds_to_answer": 11,
"disconnection_reason": "agent_hangup",
"ai_summary": "Confirmed the appointment for March 20th at 2:30 PM with Dr. Smith.",
"ai_sentiment": "Positive",
"ai_successful": true,
"created_at": "2026-03-18 14:02:11",
"completed_at": "2026-03-18 14:04:03"
}]
}
Because the intake does not de-duplicate on external_id, a lookup by external_id returns every request that carried it, newest first (up to 10). ai_summary and the other analysis fields fill in a short while after the call ends, exactly when the callback would have been sent. Errors: 400 when neither or both parameters are passed, 401 for a missing or unknown key, 404 when your campaign has no request with that reference. That includes a lookup made before the request was submitted: a reference exists only once its POST has returned 202, so start looking after the 202, or read a 404 as “not submitted yet”.
The lookup is the fallback for a callback you missed, not a live feed. Checking every 30 to 60 seconds is plenty. Posting the same external_id again places a new call, so do not re-submit a request to find out how it went.
Dynamic Variables
The variables object carries per-call values into the AI agent's prompt. The campaign can define default variables; your per-call variables are merged over those defaults (per-call wins on a key clash). Reference them in the agent prompt with double-brace syntax, e.g. {{appointment_date}}.
- Use a flat object of string-friendly key/value pairs.
- Keep keys aligned with what the agent prompt expects. An unreferenced variable is simply ignored. A placeholder the prompt references but no variable supplies is left in the prompt as written, and the agent may read it aloud, so send every key the prompt uses or give it a default on the campaign.
- Do not put secrets in variables; they become part of the call context.
Idempotency & Reconciliation
Each accepted POST creates exactly one queue entry and one call attempt sequence. The endpoint does not de-duplicate on external_id or to_number: if you POST the same record twice, the number is called twice. To stay idempotent:
- Track which records you have already submitted on your side, and POST each once.
- Set
external_idto your record's unique ID so the completion callback can be matched back without ambiguity. - On a
5xxor a network timeout where you did not receive a202, the request may still have been accepted and the call placed; reconcile with the result lookup (by yourexternal_id) or the queue view/reports before resubmitting (queueing is not idempotent; posting twice calls twice). On a4xx, fix the request first.
Concurrency & Scheduling
You can POST calls faster than they are dialed; the queue absorbs the burst and the worker drains it within the configured limits. Placement is bounded by two ceilings, both configured by your administrator:
- Campaign concurrency: maximum simultaneous calls for this campaign, from 1 to 10.
- Account concurrency: maximum simultaneous AI outbound calls across all of the account's campaigns, 5 unless your administrator has set another limit. A campaign set higher than this still runs at this ceiling.
If each result you write back uses a resource your side limits (a licensed login, an API rate limit), keep the campaign concurrency at or below that limit.
Calls submitted outside the campaign's scheduled calling window (days of week and a daily start/end time in the campaign's timezone) remain queued and are placed automatically when the next window opens. There is no need to time your POSTs to the window; load the queue whenever it suits your system.
Placing a Call at a Set Time
To have a call placed later, for example at the time a contact asked to be called back, send call_at with the request. The request is accepted with a 202 as usual, immediate reads scheduled, and the response echoes the call_at it will use. The call waits in the queue and the per-minute worker places it on its first run at or after that time, so it rings within about a minute of the time you asked for.
- Give the time with its offset (
-04:00) or in UTC (Z). A time without one is rejected, so a call never rings at a time zone you did not mean. - The campaign's calling window still applies. A
call_atoutside it is placed when the window next opens. - Concurrency, retries and the completion callback work as for any other request. A retry after an unanswered scheduled call follows the campaign's retry delay from that attempt.
- A scheduled call can be cancelled from the campaign's queue view until it is placed. Pausing the campaign with its Pause button cancels it too.
Getting Calls Answered
Mobile carriers screen incoming calls with call-protection services that score each calling number. A number with a low score can be labeled as spam or diverted without ringing the phone at all. In that case the carrier answers the call itself within two or three seconds and plays the person’s voicemail greeting, so the result reads voicemail even though the phone never rang. Four things help:
- Signed caller ID. Every call from your campaign number carries full (A) STIR/SHAKEN attestation under Cloudspire’s certificate. Nothing to do on your side.
- A caller name. Ask Cloudspire support to register an outbound caller name (CNAM) for your campaign number; carriers pick it up as their databases refresh, which can take a few days.
- Registration. Register the number at the Free Caller Registry, which reaches the major call-protection providers (First Orion, Hiya and TNS). The registry accepts only the business that makes the calls, not a provider registering on its behalf, so this one is yours to do.
- Saved contacts. Ask the people you call regularly to save the number in their contacts under a name they will recognize.
To catch a number being diverted, read seconds_to_answer: a voicemail result, or a failed one, answered within two or three seconds usually means the carrier took the call instead of ringing the phone.
Integrating an External System (CRM / ERP)
Because the endpoint is push-only, integrating a system that exposes its own API (for example, an ERP you query for the day's appointments) means writing a small connector that:
- Reads the records to call from your source system (authenticate to it, query the relevant entity).
- Maps each record to a request body:
to_numberin E.164, acontact_name, thevariablesthe agent prompt needs, andexternal_idset to the source record's unique ID. - POSTs each call to this endpoint with the campaign's Bearer key.
- Consumes the completion callback (or polls your own results store) and writes the outcome back to the source record, matched on
external_id.
The connector holds the credentials for both sides and owns the business logic of which records to call and when. Cloudspire's responsibility begins at the POST and ends at the callback.
Example: queue a call with curl
curl -X POST https://api.cloudspirevoice.com/agent/campaign-intake.php \
-H "Authorization: Bearer {campaign_api_key}" \
-H "Content-Type: application/json" \
-d '{
"to_number": "+17705551234",
"contact_name": "John Doe",
"variables": { "appointment_date": "March 20th", "appointment_time": "2:30 PM" },
"external_id": "CRM-12345",
"webhook_response_url": "https://your-system.example.com/cloudspire/callback"
}'
HTTP/1.1 202 Accepted
{ "status": "queued", "queue_id": 84213, "campaign_id": 5, "immediate": "dialing" }
Billing & Limits
- Placed AI calls draw on the account's AI Voice Minutes pool (included minutes plus any overage), the same pool inbound AI voice uses. Outbound additionally requires the AI Outbound Calling add-on.
- Without both the AI Voice Minutes pool and the AI Outbound Calling add-on, requests are accepted (
202) but stay queued and are not dialed. - The
variablespayload is capped at 64 KB per call. - Numbers on the tenant blacklist are never dialed; they are rejected at submit time (
409) or markedblacklistedat placement.