AI Agent Custom Integrations
Mid-Call Tool Webhooks
A custom integration is a tool an AI agent can invoke during a live conversation. The agent collects a defined set of fields from the caller, the platform POSTs them as JSON to an HTTPS endpoint you host, and your endpoint’s JSON reply is handed straight back to the AI as the tool result: the agent reads it and speaks from it. One mechanism covers both directions:
- Submit (push): the agent sends data to your system: a lead, a booking request, a work order. Your endpoint stores it and replies with a confirmation the agent speaks.
- Lookup (pull): the agent fetches data from your system: an order status, an account balance, an appointment slot. Your endpoint replies with the data, and the agent answers the caller with it.
Custom integrations are configured on an agent under PBX > AI > AI Agents > edit agent > the Tools section in the editor’s left menu, below the tool list. Each integration is a small form: the tool name, method and endpoint URL; what it does; what the agent says while it runs; the fields the agent collects, entered one row at a time (field name, type, whether it is required, and what to ask the caller for); and, under Connection settings, the authentication headers as name and value rows, the reply fields to read, the body style and the timeout. With the AI API Access add-on (offered on the Trial and Pro AI plans) a tenant administrator sets them up directly; without the add-on a custom integration cannot run at all, so it is an add-on to add to your plan rather than something support can configure on your behalf. This page is the contract your developers build the receiving endpoint against.
How It Works
- Each integration is defined by a tool name, a description (the AI decides when to call the tool from this text), a field list (what the AI collects from the caller before calling), and your endpoint URL.
- The tool is offered to the AI as a callable function for the whole conversation. When the conversation reaches the point the description covers (“use this to submit a service request once you have the caller’s name, address, and issue”) the AI gathers any missing fields conversationally, then invokes the tool.
- The platform sends the collected fields to your endpoint, by default as a
POSTof the body shown below, or on the method and address shape you configure if you are calling an API you already have. While the request runs, the agent can speak a configured filler line (“One moment while I get that submitted…”). - Your endpoint replies with JSON. That reply becomes the tool result the AI reads next, so whatever you return is what the agent knows and can say. On failure (timeout, non-2xx, unreachable) the AI instead receives a speakable failure and recovers the conversation (“I’m having trouble reaching that system right now”).
Request Contract
This is the default shape: one HTTPS POST per tool invocation, Content-Type: application/json. To send a different method, put collected values in the address, or send the fields without this wrapper, see Calling an API You Already Have.
{
"name": "submit_booking",
"args": {
"caller_name": "Jane Smith",
"party_size": 4,
"requested_time": "2026-08-01 19:00"
},
"call": {
"call_id": "e0c2a9f41d7b4e83",
"agent_id": 42,
"from_number": "+14045551234",
"to_number": "+14045550100"
}
}
| Field | Description |
|---|---|
name | The tool name as configured on the agent. One endpoint can serve several tools by switching on this value. |
args | The fields the AI collected, keyed exactly as the integration’s field list defines them. Types follow the field definitions (string / integer / number / boolean). An optional field the caller did not provide may be absent or arrive as an empty string, so treat the two the same way, as not provided. |
call.call_id | The stable per-conversation identifier, sent on every channel. The same value identifies the conversation in Result Webhook and campaign result deliveries (as call_id on a voice call and as chat_id on a web chat or SMS conversation) so you can join a mid-conversation submission to its post-conversation summary. On voice calls it is the switch’s own call identifier, which also appears in your call records; on web chat and SMS it identifies the conversation. Treat it as an opaque string: its format differs by channel and may change, so compare it for equality rather than parsing it. |
call.agent_id | Numeric ID of the AI agent that made the tool call. |
call.from_number / call.to_number | The call’s origin and destination (E.164), following the call’s direction: on an inbound call, from_number is the caller and to_number is the business number they dialed; on an outbound (campaign) call, from_number is the business number the agent is calling from and to_number is the person being called. Result Webhook bodies use the same semantics, so the parties always agree for one call_id. For web chat and SMS conversations these may be empty or carry the texting number. |
This body is a stable, versioned contract; existing endpoints keep working unchanged as the platform evolves.
Response Contract
Reply 200 (any 2xx) with a JSON body. The body is given to the AI verbatim as the tool result: design it as the information you want the agent to have next:
Submit example: confirm what happened so the agent can close the loop:
{ "result": "Booked. Confirmation number A-1042. A text confirmation is on its way." }
Lookup example: return the data; the agent phrases it naturally:
{ "order_status": "shipped", "carrier": "UPS", "eta": "Friday, August 1" }
- Keys and structure are yours to choose; the AI reads whatever JSON you return. Short, flat, human-meaningful values work best; the agent will paraphrase them.
- A non-JSON body is wrapped as
{"response": "<raw text>"}and still reaches the agent. - To steer the agent, return an instruction as data:
{ "error": "no appointment slots that day", "suggest": "offer the next morning instead" }. - Lookup freshness: duplicate suppression applies to writes only (
POST,PUT,PATCH): an identical write (same conversation, tool, and arguments) within the ~15-minute window returns the first recorded result rather than sending again, so an ambiguous timeout cannot submit twice. AGETis a lookup and is never suppressed; asking the same question twice in a conversation queries your system twice and gets two live answers, which is what a question like “is that slot still free?” requires.
Delivery Rules
| Rule | Detail |
|---|---|
| HTTPS only, public host | The endpoint must be https:// on a publicly-routable host. Private/internal addresses are rejected before any request is sent. |
| Success is 2xx only | Redirects are not followed; a 301/302 counts as failure (the POST body would not reach the destination). Configure the final URL. |
| Timeout | 20 seconds by default, settable from 1 to 60 per integration (5 s to connect). A caller is waiting on the line for the whole of it; answer fast, and do slow work asynchronously after replying. |
| Response size | Up to 256 KB. Larger replies abort and the tool fails. |
| Duplicate suppression (best-effort, writes only) | Applies to POST, PUT and PATCH. A GET is a lookup and is never suppressed: it always reaches your system. The platform never re-sends a request on its own, and an identical write by the AI (same conversation, tool, and arguments) within a ~15-minute window is suppressed on a best-effort basis: it receives the recorded result, or a refusal when the first attempt’s outcome is unproven (a timeout after your side may have committed). This suppression is not a distributed lock; a narrow race can let two identical requests through. Endpoints that perform irreversible actions must enforce their own idempotency (call_id plus a key derived from the submitted fields); treat platform-side suppression as an optimization, not a guarantee. |
| Failure is graceful | On timeout / non-2xx / unreachable, the agent receives a speakable failure result; the call continues; nothing crashes. |
Authenticating Requests
Add the request headers your API expects under the integration’s Authentication headers, one row per header with its name and its value. The values are fixed, so this covers any scheme that authenticates with a constant header: bearer tokens, API keys, and multi-header combinations of them:
| Header name | Value |
|---|---|
Authorization | Bearer 8f4c...e2a1 |
X-API-Key | your-key |
X-Account-Id | 40219 |
Values are stored securely and are never displayed again after you save. Editing an integration leaves the saved values in place unless you type replacements or tick Remove all headers, so an unrelated change can never quietly clear your credentials.
These are fixed header values. That covers bearer tokens, API keys and any static header scheme, including several headers together. It does not cover schemes that must be computed per request (HMAC request signing, AWS SigV4, or OAuth flows that refresh a token) because the platform sends exactly what you stored and has no signer, token exchange, nonce or body-digest step. If your API requires one of those, put a small endpoint of your own in front of it that accepts a static key from us and performs the signing.
A few headers are reserved because the platform sets them or they would change where the request lands: Host, all Content-* headers, the connection and framing headers, the proxy hints Forwarded, Via, X-Forwarded-*, X-Original-* and X-Rewrite-*, and method-override headers such as X-HTTP-Method-Override. The editor refuses these with an explanation.
When you choose the bare-fields body style, the request also carries X-Cloudspire-Call-Id and X-Cloudspire-Tool, so you can still identify the conversation and the tool. With the default Cloudspire envelope those values are already in the body, so the headers are not sent and an endpoint written before this feature existed sees exactly the request it always did.
A secret in the URL still works if that is what your API expects, but headers are preferred: a URL is easier to leak through access and proxy logs you do not control.
Endpoints that perform irreversible actions should still add their own idempotency key (call_id plus your own dedup) and sanity limits. The platform suppresses an identical write retry for about 15 minutes as a best-effort guard, but that is a safety net, not a guarantee. For cryptographically signed deliveries, the post-call Result Webhooks surface signs every payload with a per-URL secret.
Calling an API You Already Have
An integration does not have to be an endpoint built for Cloudspire. It can call your existing REST API directly.
Choose the method
GET for lookups, POST, PUT or PATCH for writes. On GET no body is sent.
A GET is treated as a read: it is never retry-suppressed, so if the agent asks the same question twice in a conversation it gets a fresh answer both times rather than a replayed one. Writes keep the duplicate-submission guard described above.
Put collected values in the address
Wrap a field name in curly braces to build a REST path or query:
https://api.example.com/v1/reservations/{confirmation_code}
https://api.example.com/v1/availability?date={requested_date}&covers={party_size}
Rules that keep this safe and predictable:
- Placeholders work in the path and query only. The host is always fixed, so an integration can never be redirected to another server by something a caller said.
- Every placeholder must name a field the integration collects, and that field must be required: an optional field would leave a hole in the address. The editor refuses the configuration otherwise, rather than letting it fail on live calls.
- Values are URL-encoded, and values containing
/,\,%, or a bare./..path segment are rejected. Identifiers, dates and reference codes all pass; anything that could reshape the address does not. - The address is sent exactly as you wrote it, with the placeholders filled. Collected fields you do not place in the address are not appended to it; if a field belongs in the query, put it there yourself:
?code={confirmation_code}. - A
#fragment is rejected. Browsers keep everything after#and never send it, so a value placed there would never reach your server.
Choose the body style
Cloudspire envelope (the default) sends the {name, args, call} body documented above. Just the collected fields sends the fields as the top-level JSON object instead, which is usually what an existing API expects:
{"caller_name": "Jane Smith", "party_size": 4, "requested_time": "2026-08-01 19:00"}
You lose nothing by choosing it; the call context is still available in the X-Cloudspire-Call-Id and X-Cloudspire-Tool headers.
Return only what the agent needs
By default the whole JSON reply is given to the AI. If your API returns a large or deeply nested object, list the fields that matter in Read these fields from the reply, one per line, using dots for nested values:
booking.reference
booking.confirmed_time
slots.0.start
The agent then sees only {"reference": ..., "confirmed_time": ..., "start": ...}: keyed by the last segment of each path. This measurably improves how reliably the agent speaks the right value.
Because the last segment becomes the key, two paths ending in the same word (customer.id and booking.id) would both arrive as id and one would replace the other. That selection is rejected when you save, naming both paths, rather than silently dropping a value at call time.
If none of the listed fields exist in the reply, the call is reported as a configuration error rather than as an empty success. That is deliberate: an empty result would read to the agent as “your system has no record of this caller”, so a mistyped path would have it confidently tell callers their booking does not exist.
Set a timeout that matches your system
The default is 20 seconds and can be set between 1 and 60. The caller is on the line waiting for the whole of it, so keep it as low as your system allows, and turn on the spoken filler line for anything slow.
Test it before you rely on it
Save the integration, then press Test this integration. The platform sends sample values (test- followed by the field name for text, 1 for numbers, true for yes/no) through exactly the same path a live call uses and shows you the status, the round-trip time, which headers were sent (names only), and your reply. A 401 tells you the credentials are wrong; a 404 with sample values usually means everything is working and the sample record simply does not exist. A test has no conversation behind it, so call.call_id and the phone numbers are empty; an endpoint that refuses a submission it cannot tie to a call will refuse the test, and that is the expected answer. Recent integration calls, below the integration list, shows what real conversations have been doing.
No endpoint yet? Use the platform’s test receiver: an address on this platform that accepts any request and performs no action. Ask Cloudspire support for your agent’s Test receiver address (staff issue it, because it carries the agent’s platform token). Set it as the integration’s URL, run a conversation or press Test this integration, and open Deliveries to the test receiver on the Tools card: each delivery is listed with the method and address, the header names (credential values are never stored), the body exactly as sent, and the conversation’s call_id. The receiver replies {"received": true, "test": true, "result": "Test request recorded. No customer-system action was performed."}, so the agent hears a plain acknowledgment rather than an invented confirmation. The same address can be sent as a campaign call’s webhook_response_url to see the completion callback, which lets you check that the two arrivals for one call carry the same call_id before your own endpoint exists. The receiver is a test window, not a store: it keeps the newest deliveries for each agent and removes older ones as new ones arrive, so move the integration to your own endpoint once it passes.
Two-Way Data Access: the Full Picture
Custom integrations are the mid-call lane. The complete set of ways data moves between your systems and an AI agent:
| Direction | When | Mechanism |
|---|---|---|
| Into the agent | Call start | Dynamic variables: caller number and CNAM are injected automatically; outbound campaign calls carry the per-call variables you queued (see the AI Outbound Calling API). |
| Into the agent | Mid-call | Custom integration (lookup): this page. The agent asks your system a question and speaks the answer. |
| Into the agent | Any time | Knowledge base articles: curated content the agent searches during the conversation. |
| Out of the agent | Mid-call | Custom integration (submit): this page. The agent pushes collected data to your system while the caller is still on the line. |
| Out of the agent | After the conversation | Result Webhooks: outcome, transcript, sentiment, and summary POSTed to your URLs, signed. Reference. |
| Out of the agent | After an outbound campaign call | Campaign result callback: the per-call result POSTed to the webhook_response_url you supplied when queueing. Reference. |
Worked Example: a Booking Tool
Configuration on the agent:
| Tool name | submit_booking: letters, numbers, underscores, dashes. This is the function name the AI calls and the name your endpoint receives. |
| What it does | “Submit a table reservation once you have the caller’s name, party size, and requested time. Use only after confirming all three back to the caller.”; the AI decides when to invoke the tool from this text, so write it as an instruction about when and with what. |
| Fields | Three rows: caller_name, Text, required, “Name for the booking”; party_size, Whole number, required, “Number of guests”; requested_time, Text, required, “Requested date and time”. These become the JSON Schema the AI fills (a Text field is a JSON string, Whole number an integer, Number a number, Yes/no a boolean); each description tells the AI what to ask for. |
| Endpoint URL | https://example.com/hooks/bookings: your system. |
| Authentication headers | One row: name Authorization, value Bearer …: sent with every request. |
| While it runs | “Let me get that booked for you.”, spoken during the request so the caller never hears dead air. |
The caller says “table for four Friday at seven, name’s Jane”; the AI confirms the details, invokes submit_booking, your endpoint books it and replies {"result":"Booked, confirmation A-1042"}, and the agent says “You’re all set; your confirmation number is A-1042.”
Worked Example: Help Desk Ticket via Catch Hook
Zapier, Make and n8n each offer a catch hook: a URL that accepts any JSON POST and starts a workflow you build in their editor. Pointing a custom integration at one turns the agent into a trigger for anything those platforms connect to, with no endpoint of your own. It is also the way to reach an API whose request body is not a flat object. Zendesk is the worked case: its create-ticket call wants the fields nested under a ticket key, which a custom integration does not produce (it sends the Cloudspire envelope or the collected fields as a flat object), so the catch hook receives the flat fields and its next step, the platform’s own Create Ticket action, does the nesting. If Zendesk is your help desk, also see the native CRM Integration, which logs every call and AI note to Zendesk without a workflow platform.
Configuration on the agent:
| Tool name | open_support_ticket |
| What it does | “Open a support ticket once you have the caller’s name, a callback number, and a one-sentence description of the problem. Confirm the description back to the caller before you submit.” |
| Fields | Three rows, all Text and required: caller_name “The caller's full name”; callback_number “Best number to call back”; issue_summary “One sentence describing the problem”. |
| Method | POST |
| Endpoint URL | The catch hook URL the platform shows you when you add the trigger: Zapier, a Zap starting with Webhooks by Zapier → Catch Hook; Make, a scenario starting with Webhooks → Custom webhook; n8n, a workflow starting with a Webhook node set to POST. |
| Body style | Just the collected fields, so the workflow sees caller_name, callback_number and issue_summary as top-level values it can map by name. The conversation id and tool name still arrive in the X-Cloudspire-Call-Id and X-Cloudspire-Tool headers. |
| Authentication headers | Make and n8n webhooks can require a header (Make: the webhook’s advanced settings; n8n: the Webhook node’s Header Auth credential); put that header here. Zapier catch hooks carry no authentication: the URL itself is the secret, so treat it as one. |
| While it runs | “One moment while I open that ticket for you.” |
Then, in the platform, add the action the trigger feeds. For Zendesk that is the platform’s Zendesk → Create Ticket action: map issue_summary to the subject, put the caller’s name and callback number with the summary in the first comment, and set the requester from caller_name. Any other system with an action in that platform works the same way.
What the agent hears back depends on the platform. A Zapier catch hook answers {"status":"success"} immediately, before the Zap has run, so the agent can confirm the request was received but cannot quote a ticket number; write its instruction accordingly (“tell the caller the request is in and someone will follow up”). Make can reply with a Webhook response module and n8n with a Respond to Webhook node, each placed after the Create Ticket step, so the reply can carry the new ticket id and the agent can read it to the caller: {"result":"Ticket 4821 opened. Someone will call back within the hour."}. Keep the whole workflow inside the integration’s timeout; the caller is on the line while it runs.