Skip to navigation

Live call control

Send commands to an active call

Live call control lets your server change a call while it’s in progress. You send each command as an HTTPS POST to the call’s controlUrl. Commands can make the assistant speak, add context to the conversation, mute either side, transfer the call, hand it off to another assistant or squad, or end it.

To stream a call’s audio and events instead, see Listen to live calls.

The control URL doesn’t require authentication by default. Turn on authentication so only requests with your public API key can control the call.

Get the control URL

Every call has its control URL in call.monitor.controlUrl. Read the URL from the call object instead of building it yourself. The host can change between calls.

The control URL accepts commands while the call is in progress. You can get it from the create call response, from server messages, or from the call record.

From the create call response

For outbound calls, the Create Call response includes monitor.controlUrl:

curl https://api.vapi.ai/call \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"assistantId": "00000000-0000-4000-8000-000000000002",
"phoneNumberId": "00000000-0000-4000-8000-000000000003",
"customer": {
"number": "+14155550100"
}
}'
Response
{
"id": "00000000-0000-4000-8000-000000000001",
"assistantId": "00000000-0000-4000-8000-000000000002",
"phoneNumberId": "00000000-0000-4000-8000-000000000003",
"type": "outboundPhoneCall",
"createdAt": "2026-10-28T17:00:00.000Z",
"updatedAt": "2026-10-28T17:00:00.000Z",
"orgId": "00000000-0000-4000-8000-000000000009",
"cost": 0,
"customer": {
"number": "+14155550100"
},
"status": "queued",
"phoneCallProvider": "twilio",
"phoneCallProviderId": "CA00000000000000000000000000000001",
"phoneCallTransport": "pstn",
"monitor": {
"listenUrl": "wss://phone-call-websocket.aws-us-west-2-backend-production1.vapi.ai/00000000-0000-4000-8000-000000000001/listen",
"controlUrl": "https://phone-call-websocket.aws-us-west-2-backend-production1.vapi.ai/00000000-0000-4000-8000-000000000001/control"
}
}

From server messages

For inbound calls, read the control URL from a server message. Server messages that include the call object carry it at message.call.monitor.controlUrl. The first status-update message with status set to in-progress arrives as soon as the call connects:

status-update message (trimmed)
{
"message": {
"type": "status-update",
"status": "in-progress",
"call": {
"id": "00000000-0000-4000-8000-000000000001",
"monitor": {
"listenUrl": "wss://phone-call-websocket.aws-us-west-2-backend-production1.vapi.ai/00000000-0000-4000-8000-000000000001/listen",
"controlUrl": "https://phone-call-websocket.aws-us-west-2-backend-production1.vapi.ai/00000000-0000-4000-8000-000000000001/control"
}
}
}
}

The tool-calls server message also includes the control URL, so a Function tool handler can control the call it’s running in. The assistant-request message doesn’t include the control URL.

The status-update message is in the default serverMessages list. If you set serverMessages on your assistant, include status-update, because your list replaces the default.

From the call record

Get Call returns monitor.controlUrl for any call. The URL stays on the call record after the call ends, so check that status is in-progress before you send a command.

Control settings

Live call control is configured in the assistant’s monitorPlan. It’s on by default and doesn’t require authentication by default.

FieldDefaultEffect
controlEnabledtrueIssues a controlUrl for the assistant’s calls
controlAuthenticationEnabledfalseRequires a public API key on every control command

For the Call Listen fields, see Listen settings.

Turn live call control on or off

Set controlEnabled to false to turn off live call control for an assistant. If any assistant on a call has controlEnabled set to false when the call starts, the call has no controlUrl.

A call that starts with control on can later reach an assistant with control off, such as after a handoff. From then on, every command returns 400 with this error:

Couldn't Control Call. One of the assistants has `assistant.monitorPlan.controlEnabled` set to false.

Require authentication

Set controlAuthenticationEnabled to true to require your public API key on every command:

{
"monitorPlan": {
"controlAuthenticationEnabled": true
}
}

Then send the public API key in the Authorization header:

curl -X POST "$CONTROL_URL" \
-H "Authorization: Bearer YOUR_PUBLIC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"type": "end-call"}'

A request with no key, or with your private API key, returns 400 with {"error":"Unauthorized"}.

Authentication applies to the whole call. If any assistant on the call has controlAuthenticationEnabled set to true, every command on that call needs the public API key, including after a handoff to an assistant that doesn’t require it.

Control actions

Send each command as a JSON body to the control URL. The examples on this page set CONTROL_URL to the call’s control URL. If you turned on authentication, add the Authorization header to each request.

A 200 response with {"status":"ok"} means the command was accepted. It doesn’t mean the command finished. For example, a transfer can still fail after the 200 response.

Action reference

typeWhat it doesFields
saySpeaks text or plays an audio filecontent, endCallAfterSpoken, interruptionsEnabled, interruptAssistantEnabled
add-messageAdds a message to the conversation historymessage, triggerResponseEnabled
controlMutes or unmutes the assistant or the customer, or says the first messagecontrol
end-callEnds the callNone
transferTransfers the call to a phone number or Session Initiation Protocol (SIP) URIdestination, content
handoffHands the call to another assistant or a squaddestination, content

Speak a message

Send a say command to make the assistant speak exact text:

curl -X POST "$CONTROL_URL" \
-H "Content-Type: application/json" \
-d '{
"type": "say",
"content": "Thanks for waiting. Your order is ready.",
"endCallAfterSpoken": false
}'

Optional fields:

  • endCallAfterSpoken: End the call after the message is spoken.
  • interruptionsEnabled: Let the caller interrupt the message. Defaults to false, which ignores the caller’s audio while the message plays.
  • interruptAssistantEnabled: Stop any speech already in progress or queued and say this message immediately. The assistant doesn’t resume the interrupted response. Defaults to false, which waits until the assistant finishes speaking.

Play an audio file

To play a pre-recorded audio file instead of synthesized speech, set content to the file’s URL:

curl -X POST "$CONTROL_URL" \
-H "Content-Type: application/json" \
-d '{
"type": "say",
"content": "https://example.com/audio/hold-message.wav"
}'

Set content to a single https:// URL with no other text. Other values are spoken as text or don’t play, as the following table shows.

content valueResult
An http:// URLSpoken by the voice provider as text
A URL with text before itSpoken by the voice provider as text
A URL with text after itNothing plays

The audio file must meet these requirements:

  • The URL is publicly reachable, or it’s a presigned URL. Vapi doesn’t send authentication headers when it downloads the file.
  • The file is WAV or MP3.
  • The file is short. Vapi downloads the whole file before playback starts, so shorter files start sooner.

Audio is cached by URL. To change an audio file, publish the new version at a new URL, such as hold-message-v2.wav. If you replace the file at the same URL, calls can keep playing the old audio.

Audio files aren’t added to the call’s transcript, messages, or artifact.messages.

Each say request plays one audio file when you send it. Audio doesn’t play automatically before each assistant turn. To play a sound at a specific point in the conversation, send a say request at that moment.

If Vapi can’t download or decode the file, nothing plays and the control request still returns a success response. Test each audio URL before you use it on live calls.

Add a message to the conversation

Send an add-message command to add a message to the conversation history. The message object takes a role of system, user, or assistant, and a content string.

The triggerResponseEnabled field controls what happens next:

  • true (default): The assistant responds right away.
  • false: The message is added silently. The assistant uses it on its next turn.

This example adds a system message and has the assistant respond:

curl -X POST "$CONTROL_URL" \
-H "Content-Type: application/json" \
-d '{
"type": "add-message",
"message": {
"role": "system",
"content": "New message added to conversation"
},
"triggerResponseEnabled": true
}'

This example adds context without interrupting the conversation:

curl -X POST "$CONTROL_URL" \
-H "Content-Type: application/json" \
-d '{
"type": "add-message",
"message": {
"role": "system",
"content": "The caller is a premium customer. Offer free shipping."
},
"triggerResponseEnabled": false
}'

If the caller is speaking when a triggered response starts, the caller can interrupt it.

Mute or unmute the assistant

Send a control command with mute-assistant to stop the assistant from speaking. Send unmute-assistant to let it speak again.

curl -X POST "$CONTROL_URL" \
-H "Content-Type: application/json" \
-d '{
"type": "control",
"control": "mute-assistant"
}'

While the assistant is muted, it still hears and transcribes the caller.

Mute or unmute the customer

Send a control command with mute-customer to stop the assistant from hearing the caller. Send unmute-customer to let it hear the caller again.

curl -X POST "$CONTROL_URL" \
-H "Content-Type: application/json" \
-d '{
"type": "control",
"control": "mute-customer"
}'

Muting the customer doesn’t change what anyone on the call hears. The assistant stops listening, and the caller’s speech isn’t transcribed. The caller gets no signal that they’re muted, so send a say command first if the caller should know.

The customer stays muted until you send unmute-customer. If you never unmute, the assistant can’t hear the caller for the rest of the call.

Say the first message

Send a control command with say-first-message to make the assistant say its first message. Use it when video recording is on and the conversation starts only after your client starts recording.

curl -X POST "$CONTROL_URL" \
-H "Content-Type: application/json" \
-d '{
"type": "control",
"control": "say-first-message"
}'

End the call

Send an end-call command to end the call:

curl -X POST "$CONTROL_URL" \
-H "Content-Type: application/json" \
-d '{"type": "end-call"}'

The call ends with the assistant-ended-call-after-message-spoken ended reason. To say goodbye first, send a say command with endCallAfterSpoken set to true instead.

Transfer the call

Send a transfer command to transfer the call to a phone number or a SIP URI. The optional content field is spoken to the caller before the transfer.

curl -X POST "$CONTROL_URL" \
-H "Content-Type: application/json" \
-d '{
"type": "transfer",
"destination": {
"type": "number",
"number": "+14155550101"
},
"content": "Transferring your call now."
}'

To transfer to a SIP URI, set the destination type to sip:

curl -X POST "$CONTROL_URL" \
-H "Content-Type: application/json" \
-d '{
"type": "transfer",
"destination": {
"type": "sip",
"sipUri": "sip:+14155550101@sip.example.com"
},
"content": "Transferring your call now."
}'

With content, the transfer starts after the message is spoken. Without content, the transfer starts right away.

The destination object takes the same fields as a Transfer Call tool destination.

Hand off to an assistant or squad

Send a handoff command to move the call to another assistant or to a squad. The optional content field is spoken to the caller before the handoff.

To hand off to a saved assistant, set assistantId:

curl -X POST "$CONTROL_URL" \
-H "Content-Type: application/json" \
-d '{
"type": "handoff",
"destination": {
"type": "assistant",
"assistantId": "00000000-0000-4000-8000-000000000004"
},
"content": "Connecting you to our billing assistant."
}'

To hand off to an assistant defined inline, set assistant. This example also sets contextEngineeringPlan so the new assistant starts without the earlier conversation:

curl -X POST "$CONTROL_URL" \
-H "Content-Type: application/json" \
-d '{
"type": "handoff",
"destination": {
"type": "assistant",
"contextEngineeringPlan": {
"type": "none"
},
"assistant": {
"name": "Billing assistant",
"firstMessage": "Hi, this is the billing assistant.",
"model": {
"provider": "openai",
"model": "gpt-4.1",
"messages": [
{
"role": "system",
"content": "You help callers with billing questions. Keep replies short."
}
]
},
"voice": {
"provider": "vapi",
"voiceId": "Elliot"
}
}
},
"content": "Handing off your call now."
}'

To hand off to a squad, set the destination type to squad and pass a squadId or an inline squad. This example defines the squad inline:

curl -X POST "$CONTROL_URL" \
-H "Content-Type: application/json" \
-d '{
"type": "handoff",
"destination": {
"type": "squad",
"squad": {
"members": [
{
"assistant": {
"name": "Support assistant",
"firstMessage": "Hi, this is the support team.",
"model": {
"provider": "openai",
"model": "gpt-4.1",
"messages": [
{
"role": "system",
"content": "You help callers with support questions. Keep replies short."
}
]
},
"voice": {
"provider": "vapi",
"voiceId": "Lily"
}
}
}
]
}
},
"content": "Connecting you to our support team."
}'

Use contextEngineeringPlan to choose the conversation history the destination receives. For every destination field, see Configure handoff destinations and Pass context and variables.

GPT-Live support

GPT-Live calls support a subset of live call control:

  • HTTP end-call
  • append-context to add speaker commentary, thinking, or instructions
  • Cold transfer on supported phone connections

The say, add-message, control, and handoff commands aren’t supported on GPT-Live calls. For request examples and limits, see the GPT-Live live call control guide.

What live call control can’t do

Live call control has a fixed set of actions. It can’t do these things:

  • Pause and resume a call: There’s no pause action. Muting both the assistant and the customer is the closest option.
  • Change the assistant’s settings: There’s no action to change the model, voice, or prompt mid-call. Add context with add-message, or hand off to an assistant that has the settings you need.
  • Control a call that has ended: Commands only work while the call is in progress.

Troubleshoot control commands

Each entry starts with the response or symptom you see, then gives the cause and fix.

A 400 response with {"error":"Unauthorized"} means the call requires authentication. Send your public API key in the Authorization header. A private API key is refused. See Require authentication.

This error means an assistant on the call has controlEnabled set to false:

Couldn't Control Call. One of the assistants has `assistant.monitorPlan.controlEnabled` set to false.

Set controlEnabled to true on every assistant the call can reach. See Turn live call control on or off.

A 400 response with Call `<id>` Not Active. means the call is no longer in progress. Don’t retry; check the call’s status and endedReason with Get Call.

A 500 response with failed to route call usually means the call has ended. This response can take about 30 seconds to arrive, so set your client timeout above 30 seconds. Retry once after 1 to 2 seconds. If it fails again, treat the call as ended.

An add-message command with triggerResponseEnabled set to true returns 200, but the caller doesn’t hear a reply. The caller was likely speaking when the response started and interrupted it. Send the command when the caller is quiet, or use a say command with exact text instead.