Telephony & Campaigns
Attach a phone number to an agent, point that number's inbound webhook at InfraVoice, and the agent answers real calls. The same service places outbound calls one at a time and runs campaigns — bulk outbound dialing from a CSV contact list, paced and capped so you never overrun your capacity.
Base URL:
https://your-infravoice-host/api/v1Auth:Authorization: Bearer usf-…— your organization key works on every endpoint on this page, including/telephony/*and/campaigns/*. A dashboard session token (JWT) is accepted too.GET /telephony/providersandGET /telephony/capabilitiestake no auth at all. Response shape:/telephony/*returns raw JSON with no envelope./campaigns/*and the agent service return{"success":true,"data":…}. Branch by route — do not write one parser for both.
There is no number-provisioning API. InfraVoice does not search, buy, port, or release phone numbers. You purchase the number from your carrier (Twilio) in their console, then attach it to an agent with
PUT /agents/{id}/telephony. Everything on this page assumes you already own the number.
Endpoints
| Method | Endpoint | Purpose |
|---|---|---|
PUT | /api/v1/agents/{id}/telephony | Attach or update an agent's number and call settings |
GET | /api/v1/agents/{id}/telephony | Read back the current binding |
POST | /api/v1/agents/{id}/versions/publish | Publish the draft — required before changes go live |
POST | /api/v1/telephony/calls/inbound | Twilio calls this. The webhook URL you paste into the carrier console |
POST | /api/v1/telephony/calls/outbound | Place an outbound AI call |
GET | /api/v1/telephony/calls | List your currently active calls |
GET | /api/v1/telephony/calls/{call_id} | Live status of one call |
POST | /api/v1/telephony/calls/{call_id}/end | Hang up |
POST | /api/v1/telephony/calls/{call_id}/transfer | Cold-transfer the live call to a human phone number |
GET | /api/v1/telephony/providers | Supported telephony providers (no auth) |
GET | /api/v1/telephony/capabilities | Per-provider optional features (no auth) |
GET | /api/v1/campaigns | List your campaigns. Accepts ?workspace_id= to scope the list to one workspace; omit it to return every campaign you own. A workspace you are not a member of returns 403. |
POST | /api/v1/campaigns | Create a campaign |
GET | /api/v1/campaigns/{campaign_id} | Fetch one campaign, including live counters |
PATCH | /api/v1/campaigns/{campaign_id} | Adjust pacing, concurrency, retries, caller ID |
POST | /api/v1/campaigns/{campaign_id}/csv-upload | Upload the contact list |
POST | /api/v1/campaigns/{campaign_id}/start | Launch or resume |
POST | /api/v1/campaigns/{campaign_id}/pause | Pause a running campaign |
POST | /api/v1/campaigns/{campaign_id}/cancel | Cancel permanently |
GET | /api/v1/campaigns/{campaign_id}/calls | Paginated per-contact results |
GET | /api/v1/campaigns/{campaign_id}/export | Download results as CSV |
Answering-machine detection (AMD) has no endpoint of its own — it is agent metadata, covered under Voicemail (AMD).
Never send internal headers. The API gateway strips
X-Internal-Serviceand everyX-User-*header from inbound public requests. Setting them does nothing except confuse your own logs.
Connect a number
Five steps from "I own a phone number" to "my agent picks up".
1. Set the workspace Twilio account (once, in the dashboard). Open Call Center → Settings and enter your Twilio Account SID and Auth Token. This is a workspace-level credential: every agent inherits it. Fifty agents means fifty numbers and one set of credentials.
2. Attach the number to an agent. Because the account is inherited, the agent only supplies the number:
curl -X PUT https://your-infravoice-host/api/v1/agents/AGENT_ID/telephony \
-H "Authorization: Bearer usf-YOUR_ORG_KEY" \
-H "Content-Type: application/json" \
-d '{
"twilio_phone_number": "+15559876543",
"call_direction": "both"
}'3. Publish the agent.
curl -X POST https://your-infravoice-host/api/v1/agents/AGENT_ID/versions/publish \
-H "Authorization: Bearer usf-YOUR_ORG_KEY"4. Point the number at InfraVoice. In the Twilio console, open that phone number and under Voice → A call comes in choose Webhook, method HTTP POST, URL https://your-infravoice-host/api/v1/telephony/calls/inbound.
5. Dial the number. InfraVoice resolves the agent from the number you dialed, starts a voice session, and streams the audio to your agent.
Saving is not publishing. Changing an agent's prompt, providers, tools, or telephony settings only updates a draft. Live calls keep using the last published version until you call
POST /api/v1/agents/{id}/versions/publish. If a change "didn't take effect", this is almost always why.
Binding fields
| Field | Type | Notes |
|---|---|---|
twilio_phone_number | string | The number to attach, E.164 (+15559876543). The only field most agents need. |
twilio_account_sid | string | Per-agent Twilio account. Leave blank to inherit the workspace account. |
twilio_auth_token | string | Per-agent auth token. Masked on read-back. Leave blank to inherit. |
call_direction | string | "inbound", "outbound", or "both". Enforced at call time. |
webhook_url | string | Your own callback URL for this agent's calls. |
human_transfer_number | string | Default destination when the agent hands off to a person. |
human_transfer_mode | string | "phone" (dial a number) or "panel" (route to a live human agent in the Call Center). |
panel_project_id | string | Which Call Center project receives panel transfers. Guarded against cross-workspace linking. |
transcribe_human_segment | bool | Also transcribe the portion of the call after a human takes over. |
generate_call_summary | bool | Produce a whole-call summary when the call ends. |
Supply twilio_account_sid and twilio_auth_token only if that agent genuinely belongs to a different Twilio account — a separate subaccount per client, for instance. Otherwise omit them and inherit.
call_directionis enforced, not advisory. A number attached to an agent whose direction is"outbound"will not answer: the resolver records a direction mismatch and the caller hears a spoken "no agent is configured for this number" followed by a hangup. For the normal case, set"both".
Read the binding
curl https://your-infravoice-host/api/v1/agents/AGENT_ID/telephony \
-H "Authorization: Bearer usf-YOUR_ORG_KEY"{
"success": true,
"data": {
"agent_id": "3f0c1a54-…",
"twilio_phone_number": "+15559876543",
"twilio_account_sid": "AC…",
"twilio_auth_token": "••••••••1f4d",
"call_direction": "both",
"human_transfer_mode": "phone",
"human_transfer_number": "+15551112222",
"transcribe_human_segment": true,
"generate_call_summary": true
},
"error": null
}The auth token is always masked for external callers; there is no API that returns it in the clear.
Inbound webhook
POST https://your-infravoice-host/api/v1/telephony/calls/inbound is the only URL you paste into a carrier console. Twilio calls it; you never do.
When a call arrives, Twilio POSTs a form-encoded body (CallSid, From, To, AccountSid). InfraVoice looks up which agent owns the dialed To number, checks that agent's call_direction, starts a voice-pipeline session, and answers with TwiML that connects the call's audio stream to the agent:
<Response>
<Connect>
<Stream url="wss://your-infravoice-host/api/v1/voice/ws/twilio/CA1234…">
<Parameter name="session_id" value="…"/>
<Parameter name="agent_id" value="…"/>
</Stream>
</Connect>
</Response>Two other outcomes are possible: a spoken message and hangup when no agent is bound to that number (or the direction doesn't permit inbound), and a short hold when your workspace has no free voice seat — the caller waits with hold audio until capacity frees up.
The rest of the callbacks are ours. InfraVoice generates and registers its own carrier callback URLs for call progress, AMD verdicts, and transfer recordings when it dials. You do not configure them, and they are not part of the public API. Configure the inbound URL and nothing else.
No signature validation. InfraVoice does not currently verify the
X-Twilio-Signatureheader on the inbound webhook. Treat the URL as sensitive and do not publish it beyond your carrier configuration.
Outbound calls
One AI call, placed on demand. Everything here returns raw JSON (no data envelope). The flow is: bind a number and publish → place the call → poll status → end or transfer. Before your first dial, confirm the agent has a working STT, LLM and TTS provider set; a provider-less agent makes the dial fail with a 4xx propagated from the voice pipeline rather than a telephony error.
Place a call
POST https://your-infravoice-host/api/v1/telephony/calls/outbound
| Field | Required | Description |
|---|---|---|
agent_id | yes | The agent that will run the conversation. |
to_number | yes | Destination, E.164. |
from_number | no | Caller ID. Falls back to the agent's configured twilio_phone_number. |
telephony_provider | no | twilio, vonage, telnyx, plivo, bandwidth, signalwire. Auto-detected from the resolved credentials when omitted. |
dynamic_variables | no | Object of values substituted into {{key}} and {{key|default}} tokens in the agent's prompt and welcome message. |
context | no | Free-text context injected into the conversation. |
user_background | no | Background about the person being called. |
call_details | no | Details about the purpose of this call. |
system_message_override | no | Replace the agent's system prompt for this call only. |
welcome_message_override | no | Replace the agent's opening line for this call only. |
session_metadata | no | Arbitrary object carried with the session for your own correlation. |
curl -X POST https://your-infravoice-host/api/v1/telephony/calls/outbound \
-H "Authorization: Bearer usf-YOUR_ORG_KEY" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "3f0c1a54-…",
"to_number": "+15551234567",
"dynamic_variables": {
"first_name": "Dana",
"appointment_time": "Thursday at 3pm"
}
}'{
"call_id": "9f1c2e70-4a55-4a0b-9b7d-6d2a1e8c33f1",
"session_id": "sess_8c31…",
"provider_call_sid": "CA7d9f2a…",
"telephony_provider": "twilio",
"status": "initiated",
"to_number": "+15551234567",
"from_number": "+15559876543",
"agent_id": "3f0c1a54-…"
}import requests
BASE = "https://your-infravoice-host/api/v1"
HEADERS = {"Authorization": "Bearer usf-YOUR_ORG_KEY"}
res = requests.post(
f"{BASE}/telephony/calls/outbound",
headers=HEADERS,
json={
"agent_id": AGENT_ID,
"to_number": "+15551234567",
"dynamic_variables": {"first_name": "Dana"},
},
timeout=30,
)
call = res.json() # raw JSON — no "data" wrapper
print(call["call_id"], call["status"])
call_idis not the Twilio CallSid.call_idis an InfraVoice UUID and is what every other endpoint on this page expects. The carrier's identifier is returned separately asprovider_call_sid.
Track a call
GET https://your-infravoice-host/api/v1/telephony/calls/{call_id}
{
"call_id": "9f1c2e70-…",
"session_id": "sess_8c31…",
"agent_id": "3f0c1a54-…",
"direction": "outbound",
"status": "in_progress",
"to_number": "+15551234567",
"from_number": "+15559876543",
"telephony_provider": "twilio",
"amd_verdict": "human"
}GET /api/v1/telephony/calls returns the same records for all of your live calls:
{
"count": 2,
"calls": [
{ "call_id": "9f1c2e70-…", "status": "in_progress", "duration": 37 },
{ "call_id": "0b41aa93-…", "status": "ringing", "duration": 0 }
]
}These reads are live-only and in-memory. The active-call registry is held in memory by the service replica that placed the call; it is not backed by the database. A call that has ended returns
404, and in a multi-replica deployment a list or lookup only sees the calls placed by the replica that served your request. Use them for real-time control of a call you just placed — for durable history (transcript, recording, summary, outcome), read the conversations API keyed byconversation_idonce the call completes.
A lookup for a call that belongs to another user also returns 404, not 403 — the API deliberately does not confirm that someone else's call_id exists.
End a call
POST https://your-infravoice-host/api/v1/telephony/calls/{call_id}/end hangs up the carrier leg and closes the voice session. No body.
curl -X POST https://your-infravoice-host/api/v1/telephony/calls/CALL_ID/end \
-H "Authorization: Bearer usf-YOUR_ORG_KEY"{ "call_id": "9f1c2e70-…", "status": "ended" }This endpoint is intentionally forgiving: an unknown call_id returns 200 with a message noting the call was not in the registry (already ended, or the service restarted), rather than an error. You can call it without first checking whether the call is still live.
Transfer to a human
POST https://your-infravoice-host/api/v1/telephony/calls/{call_id}/transfer — a cold transfer: the AI drops off and the caller is connected to the number you name. Recording is enabled on the transferred leg.
curl -X POST https://your-infravoice-host/api/v1/telephony/calls/CALL_ID/transfer \
-H "Authorization: Bearer usf-YOUR_ORG_KEY" \
-H "Content-Type: application/json" \
-d '{"to_number": "+15551112222"}'{ "call_id": "9f1c2e70-…", "transferred": true, "to_number": "+15551112222" }The transferred leg uses the agent's own provider number as caller ID. That is not cosmetic: many landline and PBX destinations reject a call presenting a caller ID the originating account does not own, so the transfer would silently fail otherwise.
Voicemail (AMD)
Answering-machine detection decides what your agent does when a voicemail box picks up instead of a person. AMD has no endpoint of its own — it is agent metadata set through the agents API, and telephony reads it at dial time.
| Key | Values | Meaning |
|---|---|---|
amd_enabled | bool | Master switch. |
amd_policy | disabled, hangup_on_machine, leave_voicemail, talk_anyway | What to do on a machine verdict. |
amd_voicemail_message | string | Required when the policy is leave_voicemail. |
amd_unknown_treated_as | human, machine | How to resolve an inconclusive verdict. Defaults to human. |
amd_verdict_timeout_s | number | How long to wait for a verdict before proceeding. Clamped to a safe range. |
When AMD is on, InfraVoice asks the carrier for asynchronous detection at dial time and applies your policy the moment the verdict arrives, on a callback it generates itself. The behavioural detail that matters most is when the agent starts talking:
leave_voicemailis the only policy that gates the opening message. The agent stays silent until the verdict lands, then either greets a human or playsamd_voicemail_messageto a machine. That costs a beat of silence at the top of every call — the price of never talking over a beep.hangup_on_machinegreets immediately and reacts afterwards — snappier for humans, but a machine hears the first second or two of your greeting before the call is dropped.talk_anywayruns the normal conversation regardless of the verdict.disabledskips detection entirely.
amd_unknown_treated_as is worth a moment's thought: defaulting unknown to human means the occasional voicemail gets talked to, while flipping it to machine means the occasional real person gets your voicemail script. Pick the failure you'd rather explain.
An unrecognised policy string silently disables AMD. There is no validation error — a typo like
"hangup_on_machines"simply means no detection at all. Copy the values from the table exactly.
Campaigns
Bulk outbound dialing: one agent, a CSV of contacts, and a background runner that dials them at a rate and concurrency you control. Campaign endpoints take your usf- organization key (or a dashboard JWT) and return the {"success":true,"data":…} shape.
1. Create
curl -X POST https://your-infravoice-host/api/v1/campaigns \
-H "Authorization: Bearer usf-YOUR_ORG_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "March renewals",
"agent_id": "3f0c1a54-…",
"pacing_per_minute": 10,
"max_concurrent_calls": 8,
"max_retries": 2,
"from_number": "+15559876543"
}'| Field | Default | Description |
|---|---|---|
name | — | Required. Display name. |
agent_id | — | Required. Must belong to one of your workspaces, or you get 403. |
pacing_per_minute | 10 | Calls started per minute. A rate, not a ceiling on live calls. |
max_concurrent_calls | 8 | Maximum simultaneously live calls. Minimum 1. |
max_retries | 2 | Retry attempts per contact. |
from_number | "" | Caller ID for the campaign. Falls back to the agent's attached number. |
schedule_start | null | ISO timestamp for a scheduled launch. |
column_map | {} | Optional explicit mapping of CSV columns. |
The campaign is created with status: "draft" and total_calls: 0. Note that pacing_per_minute, max_concurrent_calls, max_retries, from_number and schedule_start are stored inside the campaign's config object rather than as top-level fields — read them back from data.config.
2. Upload contacts
multipart/form-data, field name file, 10 MB max, UTF-8.
curl -X POST https://your-infravoice-host/api/v1/campaigns/CAMPAIGN_ID/csv-upload \
-H "Authorization: Bearer usf-YOUR_ORG_KEY" \
-F file=@contacts.csvThe phone column is auto-detected as the first header containing phone, number, mobile, cell or tel. Every other column becomes a per-contact dynamic variable, available to the agent's prompt as {{column_name}}. So this CSV:
phone,first_name,renewal_date
+15551234567,Dana,2026-04-02
+15559998888,Priya,2026-04-05…gives each call its own {{first_name}} and {{renewal_date}}.
{
"success": true,
"data": {
"total": 500,
"valid": 486,
"invalid": 14,
"phone_column": "phone",
"headers": ["phone", "first_name", "renewal_date"],
"preview": [
{ "phone": "+15551234567", "first_name": "Dana", "renewal_date": "2026-04-02" }
]
}
}Uploading a second file appends: total_calls is incremented, not replaced. Re-uploading the same CSV to fix a typo will dial everyone twice.
Check
invalidevery time. Numbers that don't normalise to E.164 are counted ininvalidand silently dropped — not queued, not reported later. Spaces, dashes, parentheses and dots are stripped and a leading+is added, but a number missing its country code will not survive. Avalidcount lower than you expected is your only warning. E.164 (+, country code, no spaces) is the single most common cause of dropped campaign rows and rejected transfers.
3. Tune pacing (optional)
curl -X PATCH https://your-infravoice-host/api/v1/campaigns/CAMPAIGN_ID \
-H "Authorization: Bearer usf-YOUR_ORG_KEY" \
-H "Content-Type: application/json" \
-d '{"pacing_per_minute": 20, "max_concurrent_calls": 12}'The two knobs do different jobs and you usually need both. pacing_per_minute throttles how fast new calls are started, protecting the far end and your carrier from a burst. max_concurrent_calls caps how many calls are live at once, protecting your own voice capacity — long conversations accumulate, so a generous pacing rate with no concurrency cap will pile up. Start conservative and raise it.
Only the keys you send are merged; omitted keys keep their current value. Nulls are ignored, so you cannot clear a value back to null through this route.
4. Start
curl -X POST https://your-infravoice-host/api/v1/campaigns/CAMPAIGN_ID/start \
-H "Authorization: Bearer usf-YOUR_ORG_KEY"Three pre-flight checks run, and each returns 400 with an actionable message:
- The campaign's status must be
draft,scheduledorpaused. Arunning,completedorcancelledcampaign will not start. - At least one valid contact must have been uploaded —
total_callsmust be greater than zero. - A caller ID must exist: either the campaign's
from_number, or a phone number on the agent's telephony settings.
5. Monitor
There is no separate stats endpoint — the counters are fields on the campaign object:
curl https://your-infravoice-host/api/v1/campaigns/CAMPAIGN_ID \
-H "Authorization: Bearer usf-YOUR_ORG_KEY"{
"success": true,
"data": {
"id": "c7a2…",
"user_id": "u_91…",
"agent_id": "3f0c1a54-…",
"name": "March renewals",
"status": "running",
"total_calls": 486,
"completed_calls": 212,
"failed_calls": 9,
"answered_calls": 174,
"config": {
"pacing_per_minute": 20,
"max_concurrent_calls": 12,
"max_retries": 2,
"from_number": "+15559876543"
},
"column_map": {},
"created_at": "2026-03-02T10:14:00Z",
"started_at": "2026-03-02T10:20:11Z",
"completed_at": null,
"updated_at": "2026-03-02T11:02:45Z"
}
}For contact-level detail, page through GET /api/v1/campaigns/{campaign_id}/calls?page=1&per_page=50&status=completed.
| Query | Default | Notes |
|---|---|---|
page | 1 | 1-based. |
per_page | 50 | 1–200. |
status | — | Exact match filter: queued, dialing, in_progress, completed, failed, … |
{
"success": true,
"data": {
"calls": [
{
"id": "cc_4d1…",
"campaign_id": "c7a2…",
"to_number": "+15551234567",
"dynamic_variables": { "first_name": "Dana", "renewal_date": "2026-04-02" },
"status": "completed",
"call_id": "9f1c2e70-…",
"conversation_id": "conv_5b8…",
"attempts": 1,
"scheduled_at": "2026-03-02T10:20:11Z",
"dialed_at": "2026-03-02T10:20:14Z",
"completed_at": "2026-03-02T10:22:39Z",
"duration_seconds": 145,
"error_message": null
}
],
"total": 486,
"page": 1,
"per_page": 50
}
}Each row carries two identifiers worth following. call_id is the live telephony call — usable with GET /api/v1/telephony/calls/{call_id} while the call is still up. conversation_id is the durable record: transcript, summary and analytics, readable through the conversations API long after the call ends.
6. Pause, cancel, export
POST /pause sets the campaign to paused and it can be resumed later with /start. POST /cancel is terminal — a cancelled campaign cannot be restarted, so use pause if there is any chance you'll continue.
GET /api/v1/campaigns/{campaign_id}/export streams a CSV attachment with columns phone, each dynamic variable, status, attempts, duration_seconds, dialed_at, completed_at, error_message. A campaign with no contacts exports just a header row.
curl -L https://your-infravoice-host/api/v1/campaigns/CAMPAIGN_ID/export \
-H "Authorization: Bearer usf-YOUR_ORG_KEY" \
-o results.csvErrors
Telephony returns errors as raw JSON; campaigns return them inside the success/error shape. Codes you will actually hit:
| HTTP | Where | Meaning and fix |
|---|---|---|
| 400 | outbound | Unsupported telephony_provider, or no from_number and no number attached to the agent. |
| 400 | transfer | to_number is not a valid E.164 number, or the call has no carrier SID yet. |
| 400 | csv-upload | FILE_TOO_LARGE — the CSV exceeds 10 MB. Split it. |
| 400 | campaign start | Wrong status, zero contacts, or no caller ID. The message says which. |
| 401 | everywhere | Invalid or revoked organization key, or an expired JWT. A revoked key stops working within ~5s of rotation. |
| 403 | campaign create | The agent_id is not in one of your workspaces. |
| 404 | call by id | Unknown call, a call that already ended, or a call you don't own — all three look identical by design. |
| 404 | campaign | Unknown campaign, or one belonging to another user. |
| 4xx | outbound | Propagated from the voice pipeline: the agent isn't ready to take a call, typically a missing STT, LLM or TTS provider. Fix the agent and publish. |
| 502 | outbound / transfer | The carrier's API rejected the request — bad credentials, unverified caller ID, or a number the account cannot dial. |
| 503 | outbound | No usable telephony credentials, or voice capacity is momentarily exhausted. Safe to retry with backoff; the campaign runner treats this as a requeue rather than a failure. |