I’m using Twilio to automate my calls, but I’m running into an issue with the call not knowing how to handle declined or no-answer calls. When either of these events happen, the phone call is sent to voicemail (this is a default behavior for most phones). Twilio has no idea how to properly handle this event. I’ve tried adding in AMD support to detect machines, but shorter voicemails or too natural sounding voicemails default to “human.” The funny thing, when I pick up the call, I’m listed as “unknown” rather than human.
My call flow is:
Call Created -> Automated message plays -> Based on condition, it may route to "askName" for more details and confirmations
When I initiate my call, and do the original call creation, I add in the voicemail configuration. But there’s no way to handle this information appropriately. Twilio can’t recognize it as a voicemail quick enough. Moreover, /handleCall
will always send me to /askName
because there’s no way the call to wait for the AMD status to update.
How do you handle Twilio interacting with a voicemail and thinking it’s human? Or how to handle the call routing appropriately based on the machine detection if machine detection is so slow?
Here’s my code:
import { Router } from 'express';
import twilio from 'twilio';
import axios from 'axios';
import mondayService from '../monday/mondayService.js';
const router = Router();
const accountSid = process.env.TWILIO_ACCOUNT_SID;
const authToken = process.env.TWILIO_AUTH_TOKEN;
const client = twilio(accountSid, authToken);
const baseUrl = process.env.BASE_URL || 'http://localhost:3000';
const awsS3Url = process.env.AWS_GOOGLE_S3_BASE_URL;
const recognizedNames = new Map(); // used to capture (in-memory) the SpeechResult
const exitedCalls = new Map();
const amdResults = new Map();
router.post('/initiateCall', async (req, res) => {
const { to, contactName, pulseId, linkedPulseId, patientName } = req.body;
console.log('Request:', req.body);
if (!to || !patientName) {
return res
.status(400)
.send('Please provide "to" and "patientName" in the request body.');
}
try {
const call = await client.calls.create({
from: process.env.TWILIO_PHONE_NUMBER,
to: to,
url: `${baseUrl}/twilio/handleCall?contactName=${encodeURIComponent(
contactName
)}&patientName=${encodeURIComponent(patientName)}`,
statusCallback: `${baseUrl}/twilio/callStatus?pulseId=${pulseId}&linkedPulseId=${linkedPulseId}`,
statusCallbackEvent: [
'initiated',
'ringing',
'answered',
'completed',
],
statusCallbackMethod: 'POST',
machineDetection: 'Enable',
asyncAmd: 'true',
asyncAmdStatusCallback: `${baseUrl}/twilio/amdStatus`,
timeout: 15,
machineDetectionSpeechThreshold: 2400,
machineDetectionSpeechEndThreshold: 500,
machineDetectionSpeechTimeout: 3000,
});
res.send({ message: 'Call initiated successfully', callSid: call.sid });
} catch (error) {
console.error('Error during call initiation:', error.message);
res.status(500).send('Failed to initiate call.');
}
});
router.post('/amdStatus', async (req, res) => {
const { CallSid, AnsweredBy } = req.body;
console.log(`AMD Status for call ${CallSid}: ${AnsweredBy}`);
if (CallSid && AnsweredBy) {
amdResults.set(CallSid, AnsweredBy);
if (
[
'machine_start',
'machine_end_beep',
'machine_end_silence',
'machine_end_other',
].includes(AnsweredBy)
) {
console.log('Detected voicemail or machine, hanging up.');
const twiml = new twilio.twiml.VoiceResponse();
twiml.hangup();
await client.calls(CallSid).update({ twiml: twiml.toString() });
}
}
res.sendStatus(200);
});
router.post('/handleCall', async (req, res) => {
const { contactName, patientName } = req.query;
const twiml = new twilio.twiml.VoiceResponse();
const messageText = contactName
? `Hi ${contactName}. This is West Coast Wound. I'm reaching out to express our sincere gratitude for referring ${patientName} to us. We truly appreciate your trust in our care, and we're committed to providing the highest level of service and support for your referrals. Thanks again for partnering with us, and have a great day!`
: `Hi, this is West Coast Wound. I'm reaching out to express our sincere gratitude for referring ${patientName} to us. We truly appreciate your trust in our care, and we're committed to providing the highest level of service and support for your referrals.`;
try {
const response = await axios.post(`${baseUrl}/googleTTS/synthesize`, {
text: messageText,
});
const audioUrl = response.data.url;
twiml.play(audioUrl);
twiml.pause({ length: 1 });
if (!contactName) {
twiml.redirect(`${baseUrl}/twilio/askName`);
}
} catch (error) {
console.error('Error during Google TTS synthesis:', error.message);
twiml.hangup();
}
res.type('text/xml');
res.send(twiml.toString());
});
router.post('/askName', async (req, res) => {
const twiml = new twilio.twiml.VoiceResponse();
const retryCount = parseInt(req.query.retryCount) || 0;
const retry = req.query.retry;
if (retryCount >= 3) {
console.log('Max retry limit reached, ending the call.');
twiml.hangup();
res.type('text/xml');
res.send(twiml.toString());
return;
}
twiml
.gather({
input: 'speech',
action: '/twilio/confirmName',
method: 'POST',
speechTimeout: '2',
language: 'en-US',
})
.play(
retry ? `${awsS3Url}/askNameRetry.wav` : `${awsS3Url}/askName.wav`
);
twiml.redirect(`/twilio/askName?retry=true&retryCount=${retryCount + 1}`);
res.type('text/xml');
res.send(twiml.toString());
});
router.post('/confirmName', async (req, res) => {
const twiml = new twilio.twiml.VoiceResponse();
const recognizedName = req.body.SpeechResult?.replace(/.$/, '').trim();
const digits = req.body.Digits;
const callSid = req.body.CallSid;
console.log('Received Request:', req.body);
if (recognizedName) {
recognizedNames.set(callSid, recognizedName);
const text = `It sounds like you said -- ${recognizedName}. If that sounds right, please press 1 to confirm. Press 2 to try again, or press 3 to end the call.`;
try {
const response = await axios.post(
`${baseUrl}/googleTTS/synthesize`,
{ text }
);
const audioUrl = response.data.url;
twiml
.gather({
action: '/twilio/handleConfirmation',
method: 'POST',
numDigits: 1,
})
.play(audioUrl);
twiml.redirect('/twilio/confirmName');
res.type('text/xml');
res.send(twiml.toString());
return;
} catch (error) {
console.error('Error during Google TTS synthesis:', error.message);
twiml.hangup();
res.status(500).send('Failed to synthesize speech.');
return;
}
}
if (digits) {
twiml.redirect('/twilio/handleConfirmation');
} else {
twiml.redirect('/twilio/askName?retry=true');
}
res.type('text/xml');
res.send(twiml.toString());
});
router.post('/handleConfirmation', (req, res) => {
const twiml = new twilio.twiml.VoiceResponse();
const digits = req.body.Digits;
const callSid = req.body.CallSid;
const confirmAudioUrl = `${awsS3Url}/thankYouGoodBye.wav`;
const exitAudioUrl = `${awsS3Url}/exitThankYou.wav`;
const retryInputAudioUrl = `${awsS3Url}/retryInput.wav`;
switch (digits) {
case '1': // User confirmed the name
twiml.play(confirmAudioUrl);
twiml.hangup();
break;
case '2': // User wants to retry
twiml.redirect('/twilio/askName?retry=true');
recognizedNames.delete(callSid);
break;
case '3': // User wants to exit
twiml.play(exitAudioUrl);
twiml.hangup();
recognizedNames.delete(callSid);
exitedCalls.set(callSid, true);
break;
default: // Invalid input, ask again
twiml.play(retryInputAudioUrl);
twiml.redirect('/twilio/confirmName');
break;
}
res.type('text/xml');
res.send(twiml.toString());
});
router.post('/callStatus', async (req, res) => {
const callStatus = req.body.CallStatus;
const callSid = req.body.CallSid;
const pulseId = req.query.pulseId;
const linkedPulseId = req.query.linkedPulseId;
const exitedCall = exitedCalls.get(callSid);
const answeredBy = amdResults.get(callSid);
console.log('Full request body:', JSON.stringify(req.body, null, 2));
console.log('Call SID:', req.body.CallSid);
console.log('Call Status:', req.body.CallStatus);
console.log('Call Duration:', req.body.CallDuration);
console.log('Answered By::', answeredBy);
if (pulseId && linkedPulseId) {
try {
let statusText;
if (
answeredBy &&
[
'machine_start',
'machine_end_beep',
'machine_end_silence',
'machine_end_other',
].includes(answeredBy)
) {
statusText = 'Incomplete: No Answer';
console.log(
`Call ${callSid} ended due to machine detection: ${answeredBy}`
);
} else if (
['initiated', 'ringing', 'answered', 'in-progress'].includes(
callStatus
)
) {
statusText = `In Progress: ${callStatus}`;
} else {
switch (callStatus) {
case 'completed':
const recognizedName = recognizedNames.get(callSid);
if (recognizedName) {
await mondayService.updateMondayContactName(
pulseId,
recognizedName
);
statusText = 'Completed: Name Received';
} else if (exitedCall) {
statusText = 'Completed: Call Exited';
} else {
statusText = 'Completed';
}
break;
case 'no-answer':
statusText = 'Incomplete: No Answer';
break;
case 'busy':
statusText = 'Incomplete: Busy';
break;
case 'failed':
statusText = 'Incomplete: Failed';
break;
case 'canceled':
statusText = 'Incomplete: Canceled';
break;
default:
statusText = 'Unknown Call Completion Status';
}
}
await mondayService.updateCallStatus(linkedPulseId, statusText);
console.log(`Call status updated to: ${statusText}`);
if (
[
'completed',
'no-answer',
'busy',
'failed',
'canceled',
].includes(callStatus)
) {
recognizedNames.delete(callSid);
exitedCalls.delete(callSid);
amdResults.delete(callSid);
}
} catch (error) {
console.error(
'Error updating call status on Monday.com:',
error.message
);
await mondayService.updateCallStatus(
linkedPulseId,
'Update Status Error'
);
}
} else {
console.log(
'Missing linkedPulseId or pulseId, cannot update Monday.com'
);
}
res.status(200).send('Call status received and processed.');
});
export default router;
I’ve tried:
-
Different machine detection configurations
-
Setting timeOuts to try and wait for AMD and putting it in handleCall before the call is made, but this of course causes a poor experience for real call recipients
Any suggestions? I’m about to ditch Twilio altogether.