Phone
Connecting phone calls to voicast with Twilio Media Streams: TwiML, cloud PBX and contact-center systems, and handing calls off to a person.
You own the phone number and the carrier (Twilio, for example). Partway through an incoming call, return <Connect><Stream> TwiML and the call audio streams to the voicast voice server, where the AI takes the call. When the AI finishes, the audio stream closes and Twilio requests your action URL; there you look at the outcome and decide whether to transfer the call to a person or hang up.
Incoming call → your TwiML (<Connect><Stream>) → the voicast AI talks
→ the AI finishes → Twilio requests action → you return <Dial> or <Hangup/>TwiML#
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect action="https://saas.example.com/twilio/after">
<Stream url="wss://voice.voicast.jp/ws">
<Parameter name="tenant" value="ten_…" />
<Parameter name="secret" value="vts_…" />
<Parameter name="from" value="+819012345678" />
</Stream>
</Connect>
</Response>| Parameter | Required | Description |
|---|---|---|
tenant |
yes | Tenant ID (ten_…) |
secret |
yes | The tenant's connection secret (vts_…, returned when the tenant was created) |
agent |
Agent ID (agt_…). Defaults to the tenant's agent |
|
from |
The caller's number. Stored as the call's from and available to tools and connections to look up the customer |
Twilio sends <Parameter> values as they are, so fill in from and the rest when your server builds the TwiML.
Node.js example#
Set the number's "A call comes in" webhook to https://saas.example.com/twilio/voice.
import express from 'express';
const app = express();
app.use(express.urlencoded({ extended: false }));
const xml = (s) => String(s).replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`);
// Incoming call: find your customer and its voicast tenant from the dialed number (To)
app.post('/twilio/voice', async (req, res) => {
const t = await findTenantByNumber(req.body.To); // { voicastTenantId, voicastSecret }
res.type('text/xml').send(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect action="https://saas.example.com/twilio/after">
<Stream url="wss://voice.voicast.jp/ws">
<Parameter name="tenant" value="${xml(t.voicastTenantId)}" />
<Parameter name="secret" value="${xml(t.voicastSecret)}" />
<Parameter name="from" value="${xml(req.body.From)}" />
</Stream>
</Connect>
</Response>`);
});Handoff#
When the AI ends the conversation, voicast writes the outcome before it closes the audio stream. Twilio then requests the <Connect> action with the CallSid, so look the call up with GET /v1/calls/by-provider/{CallSid} and return the next TwiML.
// After the AI: transfer to a person or hang up, depending on the outcome
app.post('/twilio/after', async (req, res) => {
const r = await fetch(`https://api.voicast.jp/v1/calls/by-provider/${req.body.CallSid}`, {
headers: { Authorization: `Bearer ${process.env.VOICAST_API_KEY}` },
});
const call = r.ok ? await r.json() : null;
// No record (the stream was refused), a handoff, or a failure: connect a person
const toHuman = !call || call.outcome === 'handoff' || call.outcome === 'error';
res.type('text/xml').send(
toHuman
? '<Response><Say language="ja-JP">担当者におつなぎします。</Say><Dial>+81312345678</Dial></Response>'
: '<Response><Hangup/></Response>',
);
});outcome |
What to do next, for example |
|---|---|
handoff |
<Dial> staff or a ring group |
completed, answered |
<Hangup/> (call back using the call's fields and summary) |
error |
Transfer to a person (the AI was cut off) |
hangup |
Nothing (the caller has already hung up) |
| No record (404) | Transfer to a person (see "Refused calls" below) |
The reason for the handoff is in reason, and whatever was collected so far is in fields. Show them to the person taking over so the caller is not asked again. The call.handoff_requested webhook carries the same information, but webhook delivery order is not guaranteed, so base the transfer on the call you look up in action.
Refused calls#
In these cases the voice server closes the stream immediately and no call record is created. Twilio moves on to action, where the example above transfers to a person.
tenantorsecretis wrong, or the tenant is disabled (status: disabled)- New calls are being refused for billing reasons: zero balance, overdue payment or the spending cap (Pricing and billing)
A call that has started is never cut off because the balance runs out.
Rotating the secret#
POST /v1/tenants/{id}/secret issues a new connection secret. The old one keeps working for 24 hours, so you have time to update your side.
Audio and behavior#
- Audio is Twilio Media Streams (8 kHz μ-law). voicast never hangs up calls through Twilio's REST API; call control stays with your Twilio account.
- When the caller starts talking, the AI stops mid-sentence and listens.
- If the caller is silent for a while, the AI asks again; if there is still no answer, it ends the call as
hangup. - If the audio stream reconnects with the same
CallSid(for example, after a voice server restart), the same call record is reused.
Cloud PBX and contact-center systems#
A cloud phone system, contact-center system or IVR built on Twilio can hand calls to voicast by returning the same <Connect><Stream> at the point where the AI should take over. A convenient pattern is to treat "AI reception" as one IVR step and use action to return to your own flow (ring group, voicemail and so on) when it ends.
- You can switch tenants or agents (
agent) per number or by time of day, for example to let the AI answer only after hours. - If you cannot use
action, put the verbs to run next (such as<Dial>) after</Connect>. When the stream closes, Twilio continues with the next verb. - To connect by other means than Twilio Media Streams (SIP and so on), contact us.
Trying it out#
Use a tenant in the test environment with a Twilio trial number. Test calls are counted too. To check an agent without placing a call, talking to it from the Web is quicker.