Qualify leads using AMD and IVR with Node.js

One popular use case for a Voice solution is to call numbers from a list or a database and qualify leads to see if they want to speak to a sales rep. With Sinch's Answering Machine Detection service, that's never been easier! This tutorial will walk you through how to create your own lead qualification app in Node.js.

What you need to know before you start

Before you can get started, you need the following already set up:

  • Set all Voice API configuration settings.
  • npm and a familiarity with how to install packages.
  • Node.js and a familiarity with how to create a new app.
  • ngrok and a familiarity with how to start a network tunnel. You can use any method you want to make your server accessible over the internet, but we like ngrok and it's what we'll use in this guide.
  • Postman or another method of making API requests to a server on your machine. You can use whatever you like, but Postman makes things easy, and it's what we'll use in this guide.
  • A SIP infrastructure. This is optional, as we realize not everyone has access to something like this. You can complete this tutorial without one.

Set up your Node.js application

Create and then navigate to a new folder where you want to store your project. Open a command prompt to that location and create a new node app with npm:

Copy
Copied
npm init

Accept the defaults for the application.

Install your dependencies

We will be using Express to create a lightweight webserver that will listen for requests from the Sinch servers to handle incoming calls. Because the webserver is running on your local machine, you will also need a method for making the server accessible over the internet. In this guide, we'll be using ngrok to do that, but if you have another way you'd prefer to do it, feel free to use that. Additionally, we'll be using the node-fetch package to make HTTP requests.

Use the following command to install the Express, ngrok, and node-fetch packages:

Copy
Copied
npm install express ngrok node-fetch

Create your file

In your project folder, create a new file named index.mjs in the project and paste the provided "index.mjs" code into the file.

index.mjs

This code is used to start a lightweight web server that qualifies leads.

import fetch from 'node-fetch'
import express from 'express'
import ngrok from 'ngrok'

// Find everything you need at dashboard.sinch.com/voice/apps
const APPLICATION_KEY = 'YOUR_application_key';
const APPLICATION_SECRET = 'YOUR_application_secret';
const OUTGOING_NUMBER = 'YOUR_sinch_number'; //The Sinch number you'll be using to make the callout
const SIP_URI = 'YOUR_sip_endpoint'; //The number you want to connect the call to when a lead is qualified

const PORT = 3000;

const app = express();
app.use(express.json());

app.post('/callout', (req, res) => {
  makeCallout(APPLICATION_KEY, APPLICATION_SECRET, req.query.number.toString());
  res.json();
});

app.post('/answer', async (req, res) => {
  console.log(req.body);
  let response = handleEventType(req.body);
  console.log(response);
  res.json(response);
});

function handleEventType(requestBody){
  switch (requestBody.event){
    case 'ace':
      return handleAmdStatus(requestBody.amd.status);
    case 'pie':
      return handleMenuResult(requestBody.menuResult.value);
  }
}

function handleMenuResult(menuResult){
  switch (menuResult){
    case 'sip':
      return sipResponse();
    case 'non-sip':
      return nonSipResponse();
    default:
      return defaultResponse();
  }
}

function sipResponse(){
  return {
    instructions: [
      {
        name: 'say',
        text: 'Thank you for choosing to speak to one of our sales reps! Since you have a sip infrastructure, you will now be connected.'
      }
    ],
    action: {
      name: 'connectSip',
      destination: {
        endpoint: SIP_URI
      },
      cli: TO_NUMBER,
      transport: 'tls'
    }
  };
  
}

function nonSipResponse(){
  return {
    instructions: [
      {
        name: 'say',
        text: 'Thank you for choosing to speak to one of our sales reps! If this were in production, at this point you would be connected to a sales rep on your sip network. Since you do not, you have now completed this tutorial. We hope you had fun and learned something new. Be sure to keep visiting developers.sinch.com for more great tutorials.'
      }
    ],
    action: {
      name: 'hangup',
    }
  };
}

function defaultResponse(){
  return {
    instructions: [
      {
        name: 'say',
        text: 'Thanks for trying our tutorial! Have a great day.'
      }
    ],
    action: {
      name: 'hangup'
    }
  };
}

function handleAmdStatus(amdStatus){
  switch (amdStatus){
    case 'human':
      return humanResponse();
    case 'machine':
      return machineResponse();
  }
};

function humanResponse(){
  return {
    action: {
      name: 'runMenu',
      barge: false,
      menus: [
        { id: 'main',
          mainPrompt: '#tts[Hi, you awesome person! Press 1 if you have performed this tutorial using a sip infrastructure. Press 2 if you have not used a sip infrastructure. Press any other digit to end this call.]',
          repeatPrompt: '#tts[Again, simply press 1 if you have used sip, press 2 if you have not, or press any other digit to end this call.]',
          repeats: 2,
          options: [
            {
              dtmf: 1,
              action: 'return(sip)'
            },
            {
              dtmf: 2,
              action: 'return(non-sip)'
            }
          ]
        }
      ]
      }
  };
}

function machineResponse(){
  return {
    instructions: [
      {
        name: 'say',
        text: 'Hi you awesome person! We tried to reach you to speak with you about our awesome products. We will try again later! Bye',
      },
    ],
    action: {
      name: 'hangup',
    }
  };
}

app.listen(PORT, async () => {
  const ngrokUrl = await ngrok.connect(PORT);
  console.log(`Node.js local server is publicly-accessible at ${ngrokUrl}/answer`);
  await updateUrl(ngrokUrl + '/answer');
  console.log(`Listening at http://localhost:` + PORT);
});

async function updateUrl(ngrokUrl) {
  
  const resp = await fetch(
    `https://callingapi.sinch.com/v1/configuration/callbacks/applications/${APPLICATION_KEY}`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: 'Basic ' + Buffer.from(`${APPLICATION_KEY}:${APPLICATION_SECRET}`).toString('base64')
      },
      body: JSON.stringify({
        url:
         {  
          primary: ngrokUrl
         }
      })
    }
  );
  console.log("Your callback URL has been updated on your dashboard.")
}

async function makeCallout(applicationKey, applicationSecret, number) {
    
    const calloutBody = {
        method: 'customCallout',
        customCallout: {
            ice: "{\"action\": {\"name\": \"connectPstn\", \"number\": \"" + number + "\", \"cli\": \"" + OUTGOING_NUMBER + "\", \"amd\": {\"enabled\": true }, \"locale\": \"en-US\" }}",
            enableAce: true,
            enableDice: true
        }
    };
    
    const resp = await fetch(
        `https://calling.api.sinch.com/calling/v1/callouts`,
        {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                Authorization: 'Basic ' + Buffer.from(`${applicationKey}:${applicationSecret}`).toString('base64')
            },
            body: JSON.stringify(calloutBody) 
        }
    );
    const data = await resp.json();
    console.log(data);
}

This may seem like a lot of code, but don't worry! We'll explain what everything does as we go along. This code does quite a lot of things, but if we break it down it has these basic parts:

  1. Configure your server
  2. Make a custom callout
  3. Handle callbacks with SVAML

Configure your server and callback URL

The first step is to configure your server to start on port 3000 of localhost.

Tip:

If port 3000 is already in use, feel free to change the value of the PORT constant to another available port.

Additionally, we also start an ngrok tunnel to that port so your server can communicate with the internet. At the same time we update the callback URL so that the Sinch servers can send callbacks to your server. This is handled by the following methods in the code you already copied:

Copy
Copied
const APPLICATION_KEY = 'YOUR_application_key';
const APPLICATION_SECRET = 'YOUR_application_secret';
const OUTGOING_NUMBER = 'YOUR_sinch_number'; //The Sinch number you'll be using to make the callout
const SIP_URI = 'YOUR_sip_endpoint'; //The number you want to connect the call to when a lead is qualified

const PORT = 3000;

const app = express();
app.use(express.json());

...

app.listen(PORT, async () => {
  const ngrokUrl = await ngrok.connect(PORT);
  console.log(`Node.js local server is publicly-accessible at ${ngrokUrl}/answer`);
  await updateUrl(ngrokUrl + '/answer');
  console.log(`Listening at http://localhost:` + PORT);
  ...
});

We also need to set a few configuration parameters to allow your server to connect to the Sinch servers. You can find most of the values you need on your dashboard.

ParameterYour value
APPLICATION_KEYThis is the key for your Voice app, located on the dashboard.
APPLICATION_SECRETThis is the secret for your Voice app, located on the dashboard.
OUTGOING_NUMBERThis is the number you will use to make to your outgoing call. It can be any Voice-capable number assigned to your Voice app.
SIP_URLIf you have access to a SIP infrastructure and want to use it for testing, you can enter your SIP endpoint here. If you don't have one or don't want to use it, feel free to leave this parameter alone.

Make a custom callout

To make a call out to a phone number, we need to make a callout. And because we're using AMD, we need to make a custom callout to start the lead qualification process. This is handled by the following methods in the code you already copied:

Copy
Copied
async function makeCallout(applicationKey, applicationSecret, number) {
    
    const calloutBody = {
        method: 'customCallout',
        customCallout: {
            ice: "{\"action\": {\"name\": \"connectPstn\", \"number\": \"" + number + "\", \"cli\": \"" + OUTGOING_NUMBER + "\", \"amd\": {\"enabled\": true }, \"locale\": \"en-US\" }}",
            enableAce: true,
            enableDice: true
        }
    };
    
    const resp = await fetch(
        `https://calling.api.sinch.com/calling/v1/callouts`,
        {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                Authorization: 'Basic ' + Buffer.from(`${applicationKey}:${applicationSecret}`).toString('base64')
            },
            body: JSON.stringify(calloutBody) 
        }
    );
    const data = await resp.json();
    console.log(data);
}

Custom callouts use SVAML to create their own call events, depending on what's needed. In this case, we're using a custom callout to make an Incoming Call Event (or ICE) to connect your Sinch number to a phone number on the telephone network. We also specify that AMD is enabled, so when the call is picked up (or not), the service determines whether the call was picked up a human or by an answering machine and reports back either way.

Handle callbacks with SVAML

The real magic of this solution happens once the AMD service has determined that a human answers the phone. The AMD service reports back whether a human answers or not in an Answered Call Event (or ACE). This ACE has an AMD status that we can parse and using switch statements we can have different code execute depending on the status.

If a human answers, we want to play an Interactive Voice Response menu for them. If a machine answers, since a machine can't decide an option to pick in an IVR, we just want a simple message to play for the answering machine to record.

Additionally, we need to handle what happens when the IVR response is sent to the Sinch servers. For this tutorial, if you're using a SIP infrastructure, we want you to be able to connect to it as one of the options. If you're not using a SIP infrastructure, we still want to simulate what it would be like to pick a menu option and be transfered, so we'll play a message instead of connecting a call.

Start your server

Now with all that out of the way, you can start your server and get to the good stuff! Start your server with the following command:

Copy
Copied
node index.mjs

If you watch along in the console, you should see the server start on your specified port, the ngrok tunnel start, and a notice that your callback URL was updated on your app.

Make your callout and follow the prompts

Now you're ready to make your callout and start the process! To make this callout, you'll be making a POST request to the server running on your local host, with the number you want to use in your query. Using Postman, make the following POST request:

Copy
Copied
http://localhost:3000/callout?number=<your-phone-number>

Replace <your-phone-number> with the number you want to call. Make sure the number you're using is in E.164 format with the country code and leading +.

Tip:

If you're using an Apple computer, you may need to replace the + with the escaped URL %2B.

Now just follow the prompts on your phone. If at any time you want to follow the prompts a different way, simply hang up and make the callout again.

Next steps

Check out more of our Voice tutorials or take a look at the Voice API specification

Was this page helpful?

index.mjs

This code is used to start a lightweight web server that qualifies leads.

import fetch from 'node-fetch'
import express from 'express'
import ngrok from 'ngrok'

// Find everything you need at dashboard.sinch.com/voice/apps
const APPLICATION_KEY = 'YOUR_application_key';
const APPLICATION_SECRET = 'YOUR_application_secret';
const OUTGOING_NUMBER = 'YOUR_sinch_number'; //The Sinch number you'll be using to make the callout
const SIP_URI = 'YOUR_sip_endpoint'; //The number you want to connect the call to when a lead is qualified

const PORT = 3000;

const app = express();
app.use(express.json());

app.post('/callout', (req, res) => {
  makeCallout(APPLICATION_KEY, APPLICATION_SECRET, req.query.number.toString());
  res.json();
});

app.post('/answer', async (req, res) => {
  console.log(req.body);
  let response = handleEventType(req.body);
  console.log(response);
  res.json(response);
});

function handleEventType(requestBody){
  switch (requestBody.event){
    case 'ace':
      return handleAmdStatus(requestBody.amd.status);
    case 'pie':
      return handleMenuResult(requestBody.menuResult.value);
  }
}

function handleMenuResult(menuResult){
  switch (menuResult){
    case 'sip':
      return sipResponse();
    case 'non-sip':
      return nonSipResponse();
    default:
      return defaultResponse();
  }
}

function sipResponse(){
  return {
    instructions: [
      {
        name: 'say',
        text: 'Thank you for choosing to speak to one of our sales reps! Since you have a sip infrastructure, you will now be connected.'
      }
    ],
    action: {
      name: 'connectSip',
      destination: {
        endpoint: SIP_URI
      },
      cli: TO_NUMBER,
      transport: 'tls'
    }
  };
  
}

function nonSipResponse(){
  return {
    instructions: [
      {
        name: 'say',
        text: 'Thank you for choosing to speak to one of our sales reps! If this were in production, at this point you would be connected to a sales rep on your sip network. Since you do not, you have now completed this tutorial. We hope you had fun and learned something new. Be sure to keep visiting developers.sinch.com for more great tutorials.'
      }
    ],
    action: {
      name: 'hangup',
    }
  };
}

function defaultResponse(){
  return {
    instructions: [
      {
        name: 'say',
        text: 'Thanks for trying our tutorial! Have a great day.'
      }
    ],
    action: {
      name: 'hangup'
    }
  };
}

function handleAmdStatus(amdStatus){
  switch (amdStatus){
    case 'human':
      return humanResponse();
    case 'machine':
      return machineResponse();
  }
};

function humanResponse(){
  return {
    action: {
      name: 'runMenu',
      barge: false,
      menus: [
        { id: 'main',
          mainPrompt: '#tts[Hi, you awesome person! Press 1 if you have performed this tutorial using a sip infrastructure. Press 2 if you have not used a sip infrastructure. Press any other digit to end this call.]',
          repeatPrompt: '#tts[Again, simply press 1 if you have used sip, press 2 if you have not, or press any other digit to end this call.]',
          repeats: 2,
          options: [
            {
              dtmf: 1,
              action: 'return(sip)'
            },
            {
              dtmf: 2,
              action: 'return(non-sip)'
            }
          ]
        }
      ]
      }
  };
}

function machineResponse(){
  return {
    instructions: [
      {
        name: 'say',
        text: 'Hi you awesome person! We tried to reach you to speak with you about our awesome products. We will try again later! Bye',
      },
    ],
    action: {
      name: 'hangup',
    }
  };
}

app.listen(PORT, async () => {
  const ngrokUrl = await ngrok.connect(PORT);
  console.log(`Node.js local server is publicly-accessible at ${ngrokUrl}/answer`);
  await updateUrl(ngrokUrl + '/answer');
  console.log(`Listening at http://localhost:` + PORT);
});

async function updateUrl(ngrokUrl) {
  
  const resp = await fetch(
    `https://callingapi.sinch.com/v1/configuration/callbacks/applications/${APPLICATION_KEY}`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: 'Basic ' + Buffer.from(`${APPLICATION_KEY}:${APPLICATION_SECRET}`).toString('base64')
      },
      body: JSON.stringify({
        url:
         {  
          primary: ngrokUrl
         }
      })
    }
  );
  console.log("Your callback URL has been updated on your dashboard.")
}

async function makeCallout(applicationKey, applicationSecret, number) {
    
    const calloutBody = {
        method: 'customCallout',
        customCallout: {
            ice: "{\"action\": {\"name\": \"connectPstn\", \"number\": \"" + number + "\", \"cli\": \"" + OUTGOING_NUMBER + "\", \"amd\": {\"enabled\": true }, \"locale\": \"en-US\" }}",
            enableAce: true,
            enableDice: true
        }
    };
    
    const resp = await fetch(
        `https://calling.api.sinch.com/calling/v1/callouts`,
        {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                Authorization: 'Basic ' + Buffer.from(`${applicationKey}:${applicationSecret}`).toString('base64')
            },
            body: JSON.stringify(calloutBody) 
        }
    );
    const data = await resp.json();
    console.log(data);
}