InfraVoice

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/v1 Auth: 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/providers and GET /telephony/capabilities take 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

MethodEndpointPurpose
PUT/api/v1/agents/{id}/telephonyAttach or update an agent's number and call settings
GET/api/v1/agents/{id}/telephonyRead back the current binding
POST/api/v1/agents/{id}/versions/publishPublish the draft — required before changes go live
POST/api/v1/telephony/calls/inboundTwilio calls this. The webhook URL you paste into the carrier console
POST/api/v1/telephony/calls/outboundPlace an outbound AI call
GET/api/v1/telephony/callsList your currently active calls
GET/api/v1/telephony/calls/{call_id}Live status of one call
POST/api/v1/telephony/calls/{call_id}/endHang up
POST/api/v1/telephony/calls/{call_id}/transferCold-transfer the live call to a human phone number
GET/api/v1/telephony/providersSupported telephony providers (no auth)
GET/api/v1/telephony/capabilitiesPer-provider optional features (no auth)
GET/api/v1/campaignsList 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/campaignsCreate 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-uploadUpload the contact list
POST/api/v1/campaigns/{campaign_id}/startLaunch or resume
POST/api/v1/campaigns/{campaign_id}/pausePause a running campaign
POST/api/v1/campaigns/{campaign_id}/cancelCancel permanently
GET/api/v1/campaigns/{campaign_id}/callsPaginated per-contact results
GET/api/v1/campaigns/{campaign_id}/exportDownload 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-Service and every X-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:

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

Shell
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

FieldTypeNotes
twilio_phone_numberstringThe number to attach, E.164 (+15559876543). The only field most agents need.
twilio_account_sidstringPer-agent Twilio account. Leave blank to inherit the workspace account.
twilio_auth_tokenstringPer-agent auth token. Masked on read-back. Leave blank to inherit.
call_directionstring"inbound", "outbound", or "both". Enforced at call time.
webhook_urlstringYour own callback URL for this agent's calls.
human_transfer_numberstringDefault destination when the agent hands off to a person.
human_transfer_modestring"phone" (dial a number) or "panel" (route to a live human agent in the Call Center).
panel_project_idstringWhich Call Center project receives panel transfers. Guarded against cross-workspace linking.
transcribe_human_segmentboolAlso transcribe the portion of the call after a human takes over.
generate_call_summaryboolProduce 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_direction is 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

Shell
curl https://your-infravoice-host/api/v1/agents/AGENT_ID/telephony \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY"
JSON
{
  "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:

Shell
<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-Signature header 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

FieldRequiredDescription
agent_idyesThe agent that will run the conversation.
to_numberyesDestination, E.164.
from_numbernoCaller ID. Falls back to the agent's configured twilio_phone_number.
telephony_providernotwilio, vonage, telnyx, plivo, bandwidth, signalwire. Auto-detected from the resolved credentials when omitted.
dynamic_variablesnoObject of values substituted into {{key}} and {{key|default}} tokens in the agent's prompt and welcome message.
contextnoFree-text context injected into the conversation.
user_backgroundnoBackground about the person being called.
call_detailsnoDetails about the purpose of this call.
system_message_overridenoReplace the agent's system prompt for this call only.
welcome_message_overridenoReplace the agent's opening line for this call only.
session_metadatanoArbitrary object carried with the session for your own correlation.
Shell
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"
    }
  }'
JSON
{
  "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-…"
}
Python
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_id is not the Twilio CallSid. call_id is an InfraVoice UUID and is what every other endpoint on this page expects. The carrier's identifier is returned separately as provider_call_sid.

Track a call

GET https://your-infravoice-host/api/v1/telephony/calls/{call_id}

JSON
{
  "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:

JSON
{
  "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 by conversation_id once 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.

Shell
curl -X POST https://your-infravoice-host/api/v1/telephony/calls/CALL_ID/end \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY"
JSON
{ "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.

Shell
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"}'
JSON
{ "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.

KeyValuesMeaning
amd_enabledboolMaster switch.
amd_policydisabled, hangup_on_machine, leave_voicemail, talk_anywayWhat to do on a machine verdict.
amd_voicemail_messagestringRequired when the policy is leave_voicemail.
amd_unknown_treated_ashuman, machineHow to resolve an inconclusive verdict. Defaults to human.
amd_verdict_timeout_snumberHow 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_voicemail is the only policy that gates the opening message. The agent stays silent until the verdict lands, then either greets a human or plays amd_voicemail_message to a machine. That costs a beat of silence at the top of every call — the price of never talking over a beep.
  • hangup_on_machine greets 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_anyway runs the normal conversation regardless of the verdict.
  • disabled skips 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

Shell
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"
  }'
FieldDefaultDescription
nameRequired. Display name.
agent_idRequired. Must belong to one of your workspaces, or you get 403.
pacing_per_minute10Calls started per minute. A rate, not a ceiling on live calls.
max_concurrent_calls8Maximum simultaneously live calls. Minimum 1.
max_retries2Retry attempts per contact.
from_number""Caller ID for the campaign. Falls back to the agent's attached number.
schedule_startnullISO 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.

Shell
curl -X POST https://your-infravoice-host/api/v1/campaigns/CAMPAIGN_ID/csv-upload \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -F file=@contacts.csv

The 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:

Shell
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}}.

JSON
{
  "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 invalid every time. Numbers that don't normalise to E.164 are counted in invalid and 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. A valid count 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)

Shell
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

Shell
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:

  1. The campaign's status must be draft, scheduled or paused. A running, completed or cancelled campaign will not start.
  2. At least one valid contact must have been uploaded — total_calls must be greater than zero.
  3. 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:

Shell
curl https://your-infravoice-host/api/v1/campaigns/CAMPAIGN_ID \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY"
JSON
{
  "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.

QueryDefaultNotes
page11-based.
per_page501–200.
statusExact match filter: queued, dialing, in_progress, completed, failed, …
JSON
{
  "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.

Shell
curl -L https://your-infravoice-host/api/v1/campaigns/CAMPAIGN_ID/export \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -o results.csv

Errors

Telephony returns errors as raw JSON; campaigns return them inside the success/error shape. Codes you will actually hit:

HTTPWhereMeaning and fix
400outboundUnsupported telephony_provider, or no from_number and no number attached to the agent.
400transferto_number is not a valid E.164 number, or the call has no carrier SID yet.
400csv-uploadFILE_TOO_LARGE — the CSV exceeds 10 MB. Split it.
400campaign startWrong status, zero contacts, or no caller ID. The message says which.
401everywhereInvalid or revoked organization key, or an expired JWT. A revoked key stops working within ~5s of rotation.
403campaign createThe agent_id is not in one of your workspaces.
404call by idUnknown call, a call that already ended, or a call you don't own — all three look identical by design.
404campaignUnknown campaign, or one belonging to another user.
4xxoutboundPropagated 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.
502outbound / transferThe carrier's API rejected the request — bad credentials, unverified caller ID, or a number the account cannot dial.
503outboundNo 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.