Twilio automated calls with Node.js backend, calls always “complete”

I’m having an issue with my Node.js server that automates my Twilio calls. I’m trying to update a Monday.com status based on call actions; however, when I hangup the call or don’t answer, the callStatus endpoint is called with a complete status, which isn’t true. Within Twilio’s documentation, there are final call statuses (no-answer, complete, failed, and canceled), I just can’t figure out how to get Twilio to send these to my endpoint.

I think it has to have something to do with my /initiateCall endpoint. This receives a contactName; if it was set in the call, it means we already have their name. Otherwise, the call needs to collect that data by calling the /askName endpoint.

As a side-note, I’m using Google TTS to synthesize a more natural sounding voice. That’s what the .play() calls are using.

router.post('/initiateCall', async (req, res) => {
    const { text, to, contactName, pulseId, linkedPulseId } = req.body;

    console.log('Request:', req.body);

    if (!text || !to) {
        return res
            .status(400)
            .send('Please provide both "text" and "to" in the request body.');
    }

    try {
        const response = await axios.post(`${baseUrl}/googleTTS/synthesize`, {
            text: text,
        });

        const audioUrl = response.data.url;
        console.log('audioUrl:', audioUrl);

        const twiml = new twilio.twiml.VoiceResponse();

        if (contactName) {
            twiml.play(audioUrl);
            twiml.pause({ length: 1 }); // Optional pause for effect
        } else {
            twiml.play(audioUrl);
            twiml.pause({ length: 1 });
            twiml.redirect(`${baseUrl}/twilio/askName`);
        }

        const call = await client.calls.create({
            from: process.env.TWILIO_PHONE_NUMBER,
            to: to,
            twiml: twiml.toString(),
            statusCallback: `${baseUrl}/twilio/callStatus?pulseId=${pulseId}&linkedPulseId=${linkedPulseId}`,
            statusCallbackEvent: [
                'initiated',
                'ringing',
                'answered',
                'completed',
                'no-answer',
                'busy',
                'failed',
                'canceled',
            ],
        });

        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.');
    }
});

The /askName route is below.

router.post('/askName', async (req, res) => {
    const twiml = new twilio.twiml.VoiceResponse();

    const retry = req.query.retry;

    if (req.body.CallStatus && req.body.CallStatus !== 'in-progress') {
        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');

    res.type('text/xml');
    res.send(twiml.toString());
});

I added a check for CallStatus. Within the Twilio logs, I noticed that it was being called, despite the call never even being picked up (I hang up during the call).

Then I try to handle the statuses with my /callStatus:

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);

    console.log('Received Call Status:', callStatus);
    console.log('Call SID:', callSid);
    console.log('Call Request Body:', req.body);

    if (pulseId && linkedPulseId) {
        try {
            let statusText;

            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);
            }
        } 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.');
});

I can never get the callStatus to have a failed, incomplete status.

I’m not really sure where to go from here. I’ve been testing and playing around with it for most of the day. Any thoughts?

I tried using <Dial> (twiml.dial()), but I think that’s just used to connect an external call — or I’m way off with interpreting the documentation. I was using it to include a /finalStatus endpoint to see if that would be called. I separated out the completion and in-progress statuses while using that, but the endpoint was never called at all, despite being accessible on the server.