InfraVoice

Call Center & Human Handoff

The call centre is the part of InfraVoice where live calls reach people. An AI agent answers, and when the conversation needs a human — a refund, an escalation, a signature — the call is handed to a real person without the caller being hung up on or asked to dial again.

Three things make that work: projects (the routing unit — numbers, AI agents, humans, ring/queue/wrap-up rules), human agents (your people, who ring only while the agent portal holds a live socket), and the handoff (ring → accept → connect audio → leave).

Base URL: https://your-infravoice-host/api/v1 Auth header: Authorization: Bearer usf-… (your organization key, from Account → Organization key) Workspace: nearly every call takes ?workspace_id=…. Your org key defaults to your primary workspace, but pass it explicitly — it is what scopes and isolates every read. Telephony: the call centre places real calls on your own Twilio account. Configure it once in Setup before anything else will work.


Endpoints

Everything on this page, in one place. Two services answer here and they do not answer in the same format — one response parser for the whole page will break.

Path prefixServiceResponse bodyOrg key (usf-…)
/api/v1/call-center/*call-center (Go)Envelope: {"success":true,"data":{…},"error":null,"meta":…,"timestamp":…}Yes
/api/v1/call-bridge/*call-bridge (Python)Raw JSON, no envelope; errors are {"detail":"…"}Yes

Everything on this page takes your usf- organization key. Configuration, assignments, live monitoring and history are all yours to drive from your own backend. Ringing and audio between a caller and a human agent are handled by the Agent Portal itself — you set up who can be rung and read what happened, and the platform runs the call.

Settings and projects

MethodEndpointPurpose
GET/api/v1/call-center/settings?workspace_id=…Read settings (secrets masked)
PUT/api/v1/call-center/settings?workspace_id=…Create or update settings (owner/admin only)
GET/api/v1/call-center/projects?workspace_id=…List projects (?status, ?limit, ?offset)
GET/api/v1/call-center/projects/{id}One project's configuration
GET/api/v1/call-center/projects/{id}/detailProject + live stats + assignments in one round trip
POST/api/v1/call-center/projectsCreate a project
PUT/api/v1/call-center/projects/{id}Update a project (all fields optional; adds status)
DELETE/api/v1/call-center/projects/{id}Delete a project
GET/api/v1/call-center/projects/{id}/ai-agentsAI agents assigned to the project
POST/api/v1/call-center/projects/{id}/ai-agentsAssign an AI agent
DELETE/api/v1/call-center/projects/{id}/ai-agents/{agentID}Unassign an AI agent
GET/api/v1/call-center/projects/{id}/human-agentsHuman agents assigned to the project
POST/api/v1/call-center/projects/{id}/human-agentsAssign a human agent
DELETE/api/v1/call-center/projects/{id}/human-agents/{agentID}Unassign a human agent
GET/api/v1/call-center/projects/{id}/available-agentsWho is eligible for a call right now
GET/api/v1/call-center/ai-agents/{id}/projectsReverse lookup: which projects an AI agent belongs to

Human agents

MethodEndpointPurpose
GET/api/v1/call-center/agents?workspace_id=…List agents (?limit, ?offset)
GET/api/v1/call-center/agents/{id}Fetch one agent
POST/api/v1/call-center/agentsCreate an agent
PUT/api/v1/call-center/agents/{id}Update an agent (adds status, is_active)
DELETE/api/v1/call-center/agents/{id}Delete an agent
PUT/api/v1/call-center/agents/{id}/statusSet the stored presence status
GET/api/v1/call-center/agents/online?workspace_id=…Who has a live portal socket right now
GET/api/v1/call-center/agents/me?workspace_id=…Resolve the caller to their own agent profile
GET/api/v1/call-center/agents/stats?workspace_id=…Calls handled and talk time over a date range
GET/api/v1/call-center/agents/attendance?workspace_id=…Per-day login/logout and Available/Break durations

Live call flow

MethodEndpointPurpose
POST/api/v1/call-bridge/calls/{call_id}/add-agent-legConnect the agent's audio into the conference
POST/api/v1/call-bridge/calls/{call_id}/leaveLeave: hand back to AI (default) or end
POST/api/v1/call-bridge/calls/{call_id}/holdPut the caller on hold
POST/api/v1/call-bridge/calls/{call_id}/resumeTake the caller off hold
POST/api/v1/call-bridge/calls/{call_id}/muteMute {"participant":"caller"|"agent"|"ai"}, default ai
POST/api/v1/call-bridge/calls/{call_id}/unmuteUn-mute, same body
POST/api/v1/call-bridge/calls/{call_id}/endEnd the call outright
POST/api/v1/call-bridge/calls/{call_id}/transcription/startFlag real-time transcription on
POST/api/v1/call-bridge/calls/{call_id}/transcription/stopFlag it off and cancel any active task
POST/api/v1/call-bridge/tokenMint a 1-hour Twilio Voice grant: {identity, workspace_id}
POST/api/v1/call-bridge/calls/outboundDial out from an agent's browser
POST/api/v1/call-bridge/handoverLegacy one-shot: snapshot context, record, ring

Queues and monitoring

MethodEndpointPurpose
GET/api/v1/call-center/dashboard?workspace_id=…Workspace tiles
GET/api/v1/call-center/monitor/live?workspace_id=…Live grid of in-progress calls
GET/api/v1/call-center/queues?workspace_id=…List queue records (legacy model)
GET/api/v1/call-center/queues/{id}Fetch one queue
POST/api/v1/call-center/queuesCreate a queue
PUT/api/v1/call-center/queues/{id}Update a queue
DELETE/api/v1/call-center/queues/{id}Delete a queue
POST/api/v1/call-center/queues/{id}/agentsAdd an agent: {human_agent_id, priority}
DELETE/api/v1/call-center/queues/{id}/agents/{agentID}Remove an agent

Calls, history and audit

MethodEndpointPurpose
GET/api/v1/call-center/calls?workspace_id=…Call history with filters
GET/api/v1/call-center/calls/{id}One call
GET/api/v1/call-center/calls/{id}/detailCall + segments + handovers + notes + recordings
GET/api/v1/call-center/calls/{id}/segmentsThe call's AI and human segments
GET/api/v1/call-center/calls/{id}/handoversHandovers recorded on the call
GET/api/v1/call-center/calls/{id}/notesAgent notes
GET/api/v1/call-center/calls/{id}/transcriptsDiarized turns for the human segments
GET/api/v1/call-center/calls/{id}/recordingsRecordings attached to the call
GET/api/v1/call-center/calls/by-conversation/{conversationID}/segmentsStitch a transferred call from any of its conversation ids
POST/api/v1/call-center/callsCreate a call record (rare — the platform does this itself): {workspace_id, caller_number, callee_number, direction, caller_name, queue_id, human_agent_id, recording_enabled, transcription_enabled}. At least one of caller_number / callee_number is required, and direction must be inbound or outbound if given
PUT/api/v1/call-center/calls/{id}Update a call: disposition, handler, provider SIDs
PUT/api/v1/call-center/calls/{id}/statusUpdate just the status
POST/api/v1/call-center/calls/{id}/segmentsOpen a segment manually
POST/api/v1/call-center/calls/{id}/notesAdd a note
POST/api/v1/call-center/handoversRecord an AI↔human handover (bookkeeping only)
PUT/api/v1/call-center/calls/{id}/handovers/{handoverID}/statusAccept / reject / complete a handover
POST/api/v1/call-center/call-eventsAppend an event to the call's audit timeline
GET/api/v1/call-center/dispositions?workspace_id=…List disposition codes
POST/api/v1/call-center/dispositionsCreate: {workspace_id, code, label, description, category, sort_order}
DELETE/api/v1/call-center/dispositions/{id}Delete a code — 409 IN_USE while any call still carries it
GET/api/v1/call-bridge/calls/{call_id}/recording-audio?rsid=RE…Stream a Twilio-hosted recording as audio/wav

Health probes live at /api/v1/call-center/health and /api/v1/call-bridge/health (plus /healthz, /ready, /live on each), unauthenticated, returning {"status":…,"service":…}.


Setup

A working call centre is five ordered steps. Do them in this order — a project without Twilio settings cannot dial, and an agent without a project assignment will never ring.

1. Connect Twilio

Twilio credentials are configured once per workspace, not per agent.

Shell
curl -X PUT "https://your-infravoice-host/api/v1/call-center/settings?workspace_id=WS_ID" \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "twilio_account_sid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "twilio_auth_token": "your-auth-token",
    "twilio_api_key": "SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "twilio_api_secret": "your-api-secret",
    "twilio_twiml_app_sid": "APxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "default_caller_id": "+15551234567",
    "webhook_base_url": "https://your-infravoice-host"
  }'

PUT /settings has a stricter role gate than anything else here. Owner or admin only; a manager who can create projects and agents still cannot write Twilio credentials. New credentials take effect on the next call — nothing needs restarting. Invalid Twilio identifiers or a malformed caller ID are rejected with 400 INVALID_INPUT.

Writable fields — all optional; send only what you're changing.

FieldTypeNotes
twilio_account_sidstringAC…. The account every call in this workspace is billed to.
twilio_auth_tokenstringWrite-only. Never returned.
twilio_api_keystringSK…. Used with the secret to mint browser Voice tokens.
twilio_api_secretstringWrite-only. Never returned.
twilio_twiml_app_sidstringAP…. The TwiML App from step 2 — required for the browser dialer.
default_caller_idstringE.164 number used when an outbound call doesn't specify one.
webhook_base_urlstringPublic base URL Twilio calls back on.
default_queue_iduuidFallback queue for the legacy queue model.
auto_answerboolWhether accepted rings connect without a second click.
auto_record / auto_transcribeboolWorkspace defaults for recording and transcription.
max_ring_time_secintCeiling on how long a ring may last.
max_wrap_up_time_secintCeiling on post-call wrap-up.
allow_manual_outboundboolWhether agents may dial out from the portal.
allow_transfer_to_aiboolWhether a human may hand a call back to an AI.
allow_transfer_to_humanboolWhether an AI may escalate to a person at all.

Secrets are never echoed back. GET /settings returns boolean presence flags instead — which is how a settings UI shows "configured" without ever holding the secret:

JSON
{
  "success": true,
  "data": {
    "twilio_account_sid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "twilio_api_key": "SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "has_auth_token": true,
    "has_api_secret": true,
    "twilio_twiml_app_sid": "APxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "default_caller_id": "+15551234567",
    "webhook_base_url": "https://your-infravoice-host",
    "auto_record": true,
    "allow_transfer_to_human": true
  },
  "error": null,
  "timestamp": "2026-07-26T10:00:00Z"
}

If the workspace has never been configured, GET returns {"workspace_id":"…","message":"no settings configured"} inside data rather than a 404 — check for the Twilio fields, not for an error.

2. Point your TwiML App at the dialer webhook

In the Twilio console, open the TwiML App whose SID you just saved and set its Voice URL to:

Shell
https://your-infravoice-host/api/v1/call-bridge/twiml/voice     (HTTP POST)

This is a URL you configure, never one you call. Get it wrong — most often by dropping the /api/v1/call-bridge prefix — and Twilio receives an HTML 404, the agent's browser leg dies after about a second, and you see Twilio error 16011.

Every other Twilio callback (inbound, connect, call-status, agent-leg-status, conference-status, recording-status) is generated and registered by the platform on the calls it creates. You neither configure nor call those.

3. Create a project

The project's phone_numbers are what tie inbound PSTN traffic to this routing configuration.

Shell
curl -X POST "https://your-infravoice-host/api/v1/call-center/projects" \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "workspace_id": "WS_ID",
    "name": "Support",
    "slug": "support",
    "phone_numbers": ["+15551234567"],
    "ring_timeout_seconds": 25,
    "max_queue_size": 20,
    "max_wait_seconds": 300,
    "wrap_up_seconds": 15,
    "monitor_calls_per_lead": 8,
    "transcription_enabled": true,
    "fallback_ai_agent_id": "AI_AGENT_ID"
  }'

4. Attach the AI agent and the humans

Shell
PROJECT_ID=...   # data.id from step 3
 
curl -X POST "https://your-infravoice-host/api/v1/call-center/projects/$PROJECT_ID/ai-agents" \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ai_agent_id":"AI_AGENT_ID","description":"First-line answer bot"}'
 
curl -X POST "https://your-infravoice-host/api/v1/call-center/agents" \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "workspace_id": "WS_ID",
    "display_name": "Maria Gomez",
    "agent_code": "maria",
    "email": "maria@example.com",
    "max_concurrent_calls": 1,
    "skills": ["billing"],
    "languages": ["en", "es"],
    "priority": 10,
    "role": "member"
  }'
 
curl -X POST "https://your-infravoice-host/api/v1/call-center/projects/$PROJECT_ID/human-agents" \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"human_agent_id":"HUMAN_AGENT_ID","priority":10}'

5. Have the agent log in

Maria opens the agent portal and signs in. The portal opens the live socket described in Presence and ringing and sets her status to available. Only then is she reachable — an agent row in the database with no live socket does not ring.

Shell
curl "https://your-infravoice-host/api/v1/call-center/agents/online?workspace_id=WS_ID" \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY"
JSON
{ "success": true, "data": { "online": { "HUMAN_AGENT_ID": true } }, "error": null }

Projects

A project is the routing unit. Everything the router needs to handle a call — which numbers, which AI, which humans, how long to ring, what to do when nobody answers — hangs off one project row.

Project fields

FieldTypeWhat it controls
workspace_iduuidRequired on create.
name, description, slugstringDisplay identity.
phone_numbersstring[]E.164 numbers this project owns. This is the link between a PSTN number and this routing configuration.
routing_strategystringHow the router orders eligible agents. Leave unset to take the platform default; pick the value you want in the dashboard if you're not sure which strategies your workspace uses.
ring_timeout_secondsintHow long one ring round lasts before the call moves on.
max_queue_sizeintHow many callers may wait before the queue is full.
max_wait_secondsintHow long a caller may wait before no_agent_action applies.
no_agent_actionstringWhat happens when nobody takes the call — keep the caller with the AI, play a message, or end. Pair it with no_agent_tts_message.
no_agent_tts_messagestringSpoken to the caller when the action calls for it.
wrap_up_secondsintAfter-call protection: the router skips an agent in the ring for this long once their call ends, so they can write notes. 0 disables it.
monitor_calls_per_leadintHow many live calls one team leader supervises at a time (1–50, default 8). Live calls are divided among the team leaders currently watching; past this ceiling the extra calls wait in the monitor queue — they run completely normally, only supervision is deferred until a leader frees up or another logs in.
transcription_enabledboolWhether human segments on this project are transcribed.
hold_tts_interval_seconds, hold_tts_messageint / stringPeriodic reassurance while the caller waits.
fallback_ai_agent_iduuidThe AI that keeps the caller company — and the one a hand-back returns them to.
statusstringUpdate-only; use it to pause a project without deleting it.

GET /projects/{id} returns the same shape inside the envelope:

JSON
{
  "success": true,
  "data": {
    "id": "9c1b…",
    "workspace_id": "WS_ID",
    "name": "Support",
    "slug": "support",
    "phone_numbers": ["+15551234567"],
    "ring_timeout_seconds": 25,
    "max_queue_size": 20,
    "max_wait_seconds": 300,
    "wrap_up_seconds": 15,
    "monitor_calls_per_lead": 8,
    "transcription_enabled": true,
    "fallback_ai_agent_id": "AI_AGENT_ID",
    "status": "active"
  },
  "error": null,
  "meta": null,
  "timestamp": "2026-07-26T10:00:00Z"
}

Assignments

Assignment is what turns a project from configuration into a working team. priority orders candidates within the project. One exception overrides it: an agent whose role is lead is ranked last resort no matter how high their priority — leads exist to catch escalations, not to absorb the front line.

GET /projects/{id}/human-agents returns the assignment joined with the agent, which is what a staffing screen needs:

JSON
{
  "success": true,
  "data": [
    {
      "id": "asg_…",
      "project_id": "9c1b…",
      "human_agent_id": "HUMAN_AGENT_ID",
      "priority": 10,
      "is_active": true,
      "display_name": "Maria Gomez",
      "agent_code": "maria",
      "agent_status": "available",
      "role": "member",
      "max_concurrent_calls": 1
    }
  ],
  "error": null,
  "meta": { "total": 1 },
  "timestamp": "2026-07-26T10:00:00Z"
}

GET /projects/{id}/available-agents returns the same shape narrowed to agents currently eligible — the same candidate list the router builds, before it applies wrap-up and concurrency filtering. It is the fastest way to answer "why did nobody ring?".

Legacy queue records

The /call-center/queues/* endpoints are the older V1 configuration model. Queue fields mirror the project ones: name, description, routing_strategy, max_wait_time_sec, max_queue_size, ring_timeout_sec, wrap_up_time_sec, priority, required_skills[], is_active.

Build on projects, not queues. Queues are kept for existing configurations. Projects are the current routing unit and are what the router actually reads. If you are starting today, create projects and ignore these endpoints. (Live queue depth is a different thing entirely — see Live controls.)


Human agents

A human agent is a person who can take calls: a record, a role, a set of skills, and a concurrency limit.

FieldTypeNotes
workspace_iduuidRequired.
display_namestringRequired. Shown to supervisors and in call history.
agent_codestringShort handle, e.g. maria.
emailstringTheir portal login.
phone_extensionstringOptional desk extension.
avatar_urlstringOptional.
max_concurrent_callsintHow many live calls they may hold. 1 for voice in almost all cases.
skillsstring[]Free-form tags used for skill-based ordering.
languagesstring[]Language tags, e.g. ["en","es"].
priorityintDefault ranking; a per-project assignment priority overrides it for that project.
rolestringmember (default), lead, or supervisor.

role is not cosmetic. A lead gets the Live Monitor in the agent portal and is ranked last-resort in routing — escalate-only tiering. Give it to the people you want catching what the front line couldn't, and to nobody else. Skills and languages, by contrast, order candidates; they do not create hard filters on their own, so keep the vocabulary small.

PUT /agents/{id} takes the same fields, all optional, plus status and is_active. Setting is_active: false is the clean way to take someone out of rotation — a deactivated agent never rings, on any project, without you having to unpick their assignments.

Shell
curl -X PUT "https://your-infravoice-host/api/v1/call-center/agents/$AGENT_ID" \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"is_active": false}'

Presence and ringing

This is the single most common support question. There are two different notions of "available", and they are not the same thing.

  1. The stored status column — what PUT /agents/{id}/status writes and what a roster screen displays. It is sticky: it survives a browser crash and will happily say available for someone who closed their laptop an hour ago. It is deliberately not admin-gated, so agents can set their own status.
  2. Live presence — maintained by the agent portal's WebSocket. The server pings every 25 seconds and refreshes presence on the protocol-level pong, so presence survives a browser throttling its timers in a background tab. This is what routing actually reads, and what GET /agents/online reports.

Prefer GET /agents/online for any online/offline indicator; use the stored status for the agent's own intent (Available / Break).

An agent receives a ring offer only when all of the following hold:

ConditionWhere it comes fromFails when
Activeis_active on the agent, and an active assignment to the project the call belongs toSomeone was deactivated or never assigned to this project
Status availableTheir own status — set from the portalThey are on Break, marked Away, or offline
Recently heartbeatingA live agent-portal socket that is still pongingPortal tab closed, network dropped, laptop asleep
Ring-eligible rolerole is member, or the agent has explicitly opted in to taking callsA lead or supervisor who has not turned on their own "take calls" switch — by design, so supervisors monitor without being rung

On top of those four, the router applies two live filters: an agent already at max_concurrent_calls is skipped, and an agent inside their project's wrap_up_seconds window is skipped until it expires.

The practical debugging order when someone "isn't ringing": check GET /agents/online first (is the socket up?), then GET /projects/{id}/available-agents (is the router willing to consider them?), then their status and role.

Reporting

GET /agents/stats and GET /agents/attendance both accept ?from= and ?to= as RFC3339 timestamps or plain YYYY-MM-DD dates, default to the last 30 days, and treat to as inclusive of that whole day.

Shell
curl "https://your-infravoice-host/api/v1/call-center/agents/stats?workspace_id=WS_ID&from=2026-07-01&to=2026-07-26" \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY"

Both are computed live — stats from the call segments themselves, attendance from the session event log — so they always agree with call history rather than drifting from it.


The AI-to-human handoff

Four steps in a fixed order, and the ordering is not incidental: the caller must be anchored into the conference before the agent's leg is dialled, so the agent never lands in an empty room and the caller never hears silence. The first two steps are performed by the platform — you configure who is eligible; the AI and the Agent Portal do the ringing and accepting. The audio steps are yours to drive if you are building your own experience.

Shell
AI decides to escalate


the platform rings the project's candidates


one agent accepts in the portal; the ring cancels everywhere else


POST /call-bridge/calls/{id}/add-agent-leg    audio actually connects


POST /call-bridge/calls/{id}/leave  end the call, or hand back to the AI

1. Ring

The AI triggers this itself when its transfer condition is met — you configure that on the agent (see the Agents API) and assign human agents to the project. A supervisor can also start it from the Live Monitor's Assign and Take buttons, which ring exactly one chosen person instead of the whole pool.

Direction is inferred from who is handing over to whom: ai_to_human, human_to_ai or human_to_human. The call record is created if it doesn't exist yet, so the handover and its audit event always have something to hang off. Watch it happen with GET /call-center/monitor/live and read the result afterwards from the call's handovers and segments.

2. Accept

The agent accepts in the portal. Exactly one agent wins the claim; the ring is cancelled on every other agent's socket, the winner is marked busy, the ring timeout is disarmed, and an agent_answered event is recorded on the call.

Accepting does not connect audio. It reserves the call. The next step is what the caller actually hears.

3. Connect audio

POST /api/v1/call-bridge/calls/{call_id}/add-agent-leg — raw JSON, no envelope.

FieldTypeNotes
agent_iduuidAt least one of agent_id / agent_identity is required.
agent_identitystringThe Twilio Client identity of the agent's browser, e.g. agent_<uuid>.
workspace_iduuidSelects the Twilio credentials to dial with.
Shell
curl -X POST "https://your-infravoice-host/api/v1/call-bridge/calls/$CALL_ID/add-agent-leg" \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"agent_id":"HUMAN_AGENT_ID","agent_identity":"agent_HUMAN_AGENT_ID","workspace_id":"WS_ID"}'
JSON
{ "call_id": "…", "status": "agent_added", "conference": "conf_…" }

What this does, in order: anchors the caller into the conference (ending the AI media stream), claims the call so only one human leg can exist, dials the agent's browser into the conference, and opens the human segment — which is what makes the stint show up in call history and lets the recording attach to it. A provably dead incumbent leg is taken over rather than blocking the call forever, so a crashed browser doesn't strand the caller.

4. Leave

POST /api/v1/call-bridge/calls/{call_id}/leave is how a human finishes. It has two very different behaviours and the default surprises people.

FieldTypeDefaultNotes
return_to_aibooltruetrue hands the caller back to an AI; false genuinely ends the call.
ai_agent_iduuidHand back to a specific AI agent rather than the project's fallback.
workspace_iduuidCredential scope.

return_to_ai defaults to true. An "End call" button that POSTs to /leave with an empty body will bounce your caller back to the AI and create a surprise extra AI segment on the call. If you mean end, send {"return_to_ai": false} explicitly. Every time.

Shell
curl -X POST "https://your-infravoice-host/api/v1/call-bridge/calls/$CALL_ID/leave" \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"return_to_ai": false, "workspace_id": "WS_ID"}'

Response status is one of agent_left, agent_left_caller_returned_to_ai, call_ended or no_active_leg. Either path releases the agent's busy state and starts their wrap-up window, and clears the single-leg claim so a later transfer round can ring again. It also marks the exit as intentional, which is what stops the platform's crash-rescue logic from redirecting a caller who was deliberately hung up on. Hand-back is possible at all because the agent's conference leg is created with end_conference_on_exit=false — the human can walk away without dropping the caller.

Changing the AI agent's own configuration is a draft until you publish it. If your escalation work involves editing the AI agent — its prompt, its transfer behaviour, its providers or tools — saving only updates a draft version. Live calls keep using the last published version until you call POST /api/v1/agents/{id}/versions/publish. Call-centre changes on this page (projects, agents, assignments, settings) are different: they take effect immediately, with no publish step.

Bookkeeping handovers

POST /api/v1/call-center/handovers records that a transfer happened. Body: {call_id, direction, from_segment_id, to_human_agent_id, to_ai_agent_id, reason, context_snapshot{}}.

This endpoint moves no audio. It does not ring anyone and does not touch the live call — it only records that a handover happened, for your own audit trail. Moving a live call is done by the AI's transfer condition or by a supervisor in the Live Monitor. The call_id must already exist, or the create fails on a foreign key.

There is also an older single-shot orchestration path, POST /api/v1/call-bridge/handover, which snapshots conversation context, creates the handover record and rings the target in one request: {call_id, direction, to_human_agent_id, to_ai_agent_id, reason, conversation_id}{handover_id, call_id, direction, status, context_messages}. Supply conversation_id and it pulls the transcript so far into the handover's context snapshot. New integrations should prefer transfer + add-agent-leg, which give you control over each step.


Live controls

Hold, mute, transcription and the supervisor views — everything you touch while a call is still up. The call-bridge endpoints here return raw JSON.

Mute is how a "silent AI" works. Muting the ai participant leaves the AI connected — it keeps listening and transcribing while the human talks, and can be un-muted the moment the human hands back. Removing it instead would lose the thread.

A 200 on hold/resume does not prove the hold applied. If the conference or the participant isn't known yet — typically because the leg hasn't finished joining — you get 200 with "status": "deferred". Treat deferred as "not yet", not as success. A 404 means this replica doesn't know the call.

Use /end for a call the agent originated. For a transferred call, prefer /leave with return_to_ai:false — it goes through the release and wrap-up path properly. Both mark the exit as intentional, so neither trips the crash-rescue logic described above.

Abandoned transfers are reclaimed automatically. Registering a transfer creates bridge state that normally lives until the call ends — but if nobody ever accepts (the caller hangs up while it rings, or no agent takes it), none of the events that would clean it up ever fire. A background sweeper reclaims those after about 15 minutes, and any bridge at all after 6 hours. It confirms with Twilio that the leg is really gone first, so a genuinely long call is never affected. The practical consequence for an integration: call_id for an abandoned transfer stops resolving once it is reclaimed, and the call-bridge routes above start returning 404. Treat that as "this call is over", not as an error to retry.

Supervisor views

GET /dashboard returns total_agents, available_agents, busy_agents, away_agents, active_calls, queued_calls, completed_today, avg_talk_time_ms, avg_wait_time_ms, total_handovers and abandoned_today. available_agents is derived from live presence rather than the stored status column, so it reflects reality. A background sweeper closes calls stuck in an active state for more than 30 minutes, so active_calls doesn't inflate forever after a crashed leg.

GET /monitor/live powers the team-leader view — one row per in-progress call:

JSON
{
  "success": true,
  "data": [
    {
      "call_id": "…",
      "conversation_id": "…",
      "direction": "inbound",
      "status": "in_progress",
      "caller_number": "+1555…",
      "caller_name": "Ana Ruiz",
      "current_handler_type": "ai",
      "current_ai_agent_id": "…",
      "current_human_agent_id": null,
      "initiated_at": "2026-07-26T10:02:11Z",
      "checklist": { "done": 2, "total": 5, "summary": "Verified identity…", "updated_at": "…", "items": [] }
    }
  ],
  "error": null
}

checklist is omitted entirely until the analysis worker has produced something for that call, so treat it as optional. Poll this alongside GET /call-center/agents/online and you have the full supervisor picture: what's in flight and who's actually there.

Assign and Take — pulling a live call to a chosen person — is done from the Live Monitor in the dashboard, or by a team leader in the Agent Portal. It rings exactly one person instead of the pool.

The browser softphone

POST /api/v1/call-bridge/token with {identity, workspace_id} mints a one-hour Twilio Voice grant so an agent's browser can hold call legs, and POST /api/v1/call-bridge/calls/outbound with {workspace_id, human_agent_id, to_number, from_number, recording_enabled, transcription_enabled} dials out from it.

Both act on your Twilio account and bill you. Request them only from your own backend or the agent portal — never expose them to untrusted callers, and never mint a token for an identity you haven't authenticated yourself.


Calls and history

GET /calls accepts ?status, ?direction, ?from, ?to, ?search, ?limit, ?offset, and returns meta.total for paging.

Shell
curl "https://your-infravoice-host/api/v1/call-center/calls?workspace_id=WS_ID&status=completed&direction=inbound&from=2026-07-01&limit=25" \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY"

Reading one call

Use GET /calls/{id}/detail. One request gets you the call, every segment, every handover, the agents' notes and the recordings — exactly what a review screen renders, and it saves five round trips:

JSON
{
  "success": true,
  "data": {
    "call": { "id": "…", "direction": "inbound", "status": "completed", "caller_number": "+1555…" },
    "segments": [ { "handler_type": "ai" }, { "handler_type": "human", "human_agent_id": "…" } ],
    "handovers": [ { "direction": "ai_to_human", "reason": "billing dispute" } ],
    "notes": [],
    "recordings": [ { "storage_type": "s3", "url": "https://…" } ]
  },
  "error": null
}

A transferred call is several segments of one call, not several calls: AI, then human, then possibly AI again. If all you hold is a conversation id from one of those legs, GET /calls/by-conversation/{conversationID}/segments gives you the whole chain from any link in it. It returns an empty list, not a 404, when that conversation was never part of a multi-segment call.

Two timing facts worth knowing before you build a UI on this:

  • /transcripts is written after the call, once, by the post-call analysis worker. An empty list right after hang-up is normal, not a bug — poll or fetch it later.
  • Recording URLs are re-signed on read, so playback keeps working long after the original signed URL would have expired.

Playing a recording

Check storage_type on each recording. Archived recordings play straight from their signed URL. But when storage_type is twilio, the media URL needs Twilio account credentials and will not play in an <audio> tag — the browser gets an auth prompt instead. Fetch it through GET /api/v1/call-bridge/calls/{call_id}/recording-audio?rsid=RE… and hand the player a blob.

The rsid must be the recording SID (RE followed by 32 alphanumerics) and is strictly validated — a malformed one is a 400. The upstream media URL is built from the call's own account plus that SID, never from anything else you send. Responses are Cache-Control: private, max-age=600.

Wrap-up and audit

After a call, an agent stamps an outcome. That's PUT /calls/{id} with a disposition_code_id and disposition_notes:

Shell
curl -X PUT "https://your-infravoice-host/api/v1/call-center/calls/$CALL_ID" \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"disposition_code_id":"DISP_ID","disposition_notes":"Refund issued, ticket #4471","customer_satisfaction":5}'

The same endpoint also accepts status, current_handler_type, current_human_agent_id, current_ai_agent_id, provider_call_sid, conference_sid and linked_conversation_id — all optional. Disposition codes themselves are workspace configuration under /call-center/dispositions.

Send "disposition_code_id": "" to clear a stamped code. That matters for deletes: DELETE /call-center/dispositions/{id} returns 409 IN_USE while any call still references the code, so clear it from those calls first.

POST /call-center/call-events appends to the same timeline the platform writes to:

Shell
curl -X POST "https://your-infravoice-host/api/v1/call-center/call-events" \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "call_id": "'"$CALL_ID"'",
    "workspace_id": "WS_ID",
    "project_id": "'"$PROJECT_ID"'",
    "event_type": "notes_added",
    "human_agent_id": "HUMAN_AGENT_ID",
    "details": {"ticket_id": "4471"}
  }'

call_id and event_type are required; omitting either is a 400.

event_type is a closed vocabulary, not a free-text label. It is constrained at the database level, so an unlisted value returns 400 INVALID_EVENT_TYPE and writes nothing. Put your own system's identifiers in details — that field is free-form JSON and is what a CRM ticket id, order number or external event name belongs in.

The accepted values are:

call_initiated · call_queued · call_dequeued · queue_position_changed · queue_max_wait_expired · agent_ringing · agent_answered · agent_rejected · agent_timeout · agent_busy · agent_disconnected · ring_timeout_expired · call_connected · call_ended · caller_disconnected · hold_started · hold_ended · tts_hold_played · mute_toggled · transfer_initiated · transfer_completed · transfer_failed · recording_started · recording_stopped · voicemail_started · voicemail_saved · wrap_up_started · wrap_up_ended · notes_added · disposition_set

The timeline is append-only — events cannot be edited or removed, which is the point of an audit trail.


Errors

The envelope service (/call-center) returns {"success":false,"error":{"code":"…","message":"…"}}. Call-bridge returns {"detail":"…"}. Handle both if you touch both.

HTTPWhereMeaning and fix
400PUT /settingsINVALID_INPUT — a malformed Twilio identifier or caller ID. Check the SID prefixes (AC, SK, AP) and that the caller ID is E.164.
400POST /call-eventscall_id or event_type missing.
400POST /call-eventsINVALID_EVENT_TYPEevent_type is not in the closed vocabulary above. Put custom identifiers in details.
404POST /call-eventsCALL_NOT_FOUND — no call with that call_id.
409DELETE /dispositions/{id}IN_USE — calls still carry the code. Clear it with PUT /calls/{id} {"disposition_code_id": ""} first.
400add-agent-legNeither agent_id nor agent_identity was sent.
400recording-audioMalformed rsid — it must be RE plus 32 alphanumerics.
403any by-id routeThe resource belongs to another workspace.
403POST /transferNo workspace_id, and no to_project_id that resolves to one.
404call-bridge call routesThe bridge doesn't know that call on this replica — usually a stale call_id, or a transfer that was registered and then abandoned (see below).
409add-agent-legA different agent already holds the human leg. Retrying as the same agent is fine and idempotent.
502add-agent-legThe dial to Twilio failed.
503call-bridgeTwilio isn't configured — set up credentials in Call Center Settings.

Three non-obvious successes are worth repeating, because each one has caused a production bug:

  • accept returns 200 with "status":"already_taken" when another agent won. It is not an HTTP error. Branch on the body.
  • hold / resume return 200 with "status":"deferred" when the participant isn't known yet. Not an error, not a success either.
  • leave defaults to return_to_ai: true. Sending an empty body from an "End call" button hands your caller back to the AI.

Two more shapes that look like errors but aren't: GET /agents/me returns data: null, not a 404, when the caller has no agent profile (and it only means anything with a user login — with an org key the subject is the organization owner), and GET /settings on an unconfigured workspace returns a message inside data rather than a 404.

Finally: workspace scoping is enforced per resource. Every by-id read resolves the resource's workspace and rejects cross-tenant access. Passing workspace_id explicitly on list endpoints is the habit to build — the default only ever resolves to your primary workspace.

Ready to build? Start with settings and one project, put a single agent in it, and watch GET /projects/{id}/available-agents flip to non-empty when they log in. Everything else on this page is a variation on that loop.