Twilio Voice IVR Bridge – Connect a Call in Context Across the PSTN

February 09, 2021
Written by
Reviewed by
Toby Allen
Twilion

Customer Context transfers over the PSTN

In this tutorial, you'll learn how to build a bridge between two voice platforms so you can pass a call between the two in context across a PSTN call transfer. We’ll use Twilio Programmable Voice, and Twilio Functions, Twilio Sync, and TaskRouter to build a signalling bridge on the Twilio Platform.

Maintain customer call context across a PSTN transfer

Recently we worked with an organisation where they needed something similar. Most customers would have their central number stored: think an easy to remember shortcode or 1300 or 1800 number.

A customer would navigate the IVR with its enriched systems of record, then identify and authenticate themself. The issue would come after they authenticated – it would become clear they were serviced by another business unit on a different platform not connected to the existing IVR.

Their current platform does not support SIP. It could only support PSTN transfer. Any transfer of a call would be without context, as metadata could not be transferred. This would lead to a poor customer experience – having to ask the customer to authenticate again, and navigate the new IVR.

Now, imagine your business uses external business partners to front your calls and may white-label your products. There may be a requirement to connect their IVR to yours with context.

Picture of a suspension bridge from Pixabay on Pexels

In most cases, you could transfer the calls via SIP and keep the context; however, we know from experience not every voice platform can handle SIP.

Instead of offering your customers a poor experience by cold transferring across the PSTN - what if you could build a bridge on Twilio to do this?

Prerequisites

Be sure to perform the following prerequisites to complete this tutorial. You can skip ahead if you've already completed these tasks.

  1. Sign up for a Twilio Account
  2. Download the Twilio CLI
  3. Download Postman for Testing

This next section assumes you have successfully created a project in Twilio, have installed the CLI, and are familiar with pushing releases to your new environment.

The code can be found here by cloning this repo:

git clone https://github.com/mmeisels/ivrbridge.git

The solution: building the signalling bridge

To mitigate the poor customer experience we created a bridge between the two platforms.

The host platform would need to pass the customer’s details to us to hold to successfully identify the inbound caller. The caller would then need to be transferred and mapped back to the metadata we received about that caller.

To do this, we created a bespoke Web Service inside of Twilio Functions. The host platform would call this Web Service to pass across the information about the caller. We would then store these details in a Key Value Store. For this we used Twilio Sync.

Twilio Sync is a hosted real-time State Platform packaged with a full set of APIs to conveniently set up and manage the store. (We will discuss the use of Sync a bit later on.)

Once we stored the details against an available transfer number we would return the number for the host to transfer the call.

The call would be transferred to this number, and we would look up the contextual information in the Sync Map and use this details as appropriate. We would release the transfer number so this could be re-used again and clear the customer details.

Architecture diagram for passing call context across the PSTN

How we built the PSTN transfer signalling app

The code for this can be found in this repository in GitHub.

To clone this repository please do the following.

git clone https://github.com/mmeisels/ivrbridge.git

Twilio Modules we will need:

1. Twilio Sync Service

The Twilio Sync Service is the top level service domain which hosts all of the resources underneath. Think of it as the top level application that will hold our Sync Maps.

To generate this new Twilio Sync Service, please read the notes here or execute this from the Twilio CLI. Please note the Twilio Sync Service SID.

twilio api:sync:v1:services:create

2. 2 x Sync Maps

The Sync Map is a document store that holds JSON Objects data connected to a key value. Developers can store data within this key-value store, then be able to retrieve and manipulate the document via the Key.

The reason why we need 2 is one will hold the list of available numbers that we can use while the other will store data for reserved items.

To generate this new Twilio Sync Map, please read the notes here or execute this from the Twilio CLI. Please replace the ISxx with the Sync Service from step 1. Please take note of the two Sync Maps as you will need this to replace in the env.example file.

twilio api:sync:v1:services:maps:create \ --service-sid ISXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

3. Twilio TaskRouter Workspace

TaskRouter is Twilio’s orchestration engine. Normally it’s used to receive workloads in the form of Tasks and to identify the rules to process the information. It can then identify which systems or people have the right skills to handle this task.

To generate this new Twilio Taskrouter please read the notes here, or execute this from the Twilio CLI. Please replace the ISxx with the Sync Service from step 1. Please take note of the two Sync Maps as you will need this to replace in the env.example file.

twilio api:taskrouter:v1:workspaces:create \
           --event-callback-url <<THIS NEEDS TO BE POINT TO THE TWILIO FUNCTIONS syncWebHook function>>  \
   --template FIFO \
   --friendly-name NewWorkspace

4. Twilio Functions Service

We are using Twilio Functions to run our bespoke application. Once you have cloned the github repo you can deploy the Twilio Functions to your environment using the Twilio CLI.

twilio serverless:deploy

Design Consideration

In order to build this successfully, we needed to take into account the following items:

  1. We needed a service which we can expose to allow the host platform to pass contextual data to us. We also needed to store this information next to a phone number key that we would pass back to the host system.
  2. We would need a service to tie to the transfer phone number that we provided in point number 1. Upon receiving a call on this number, we would have to look up the stored data and enrich the call together with contextual information.
  3. If we were to receive more requests to transfer calls than we had available numbers, we would need to scale our service without causing issues to the host platform. We would have to error handle this ourselves.
  4. In the event the host platform did not successfully transfer the call or the customer hung up before we could receive the call, we would need a service to clean itself up and not lock the phone number in a limbo state.
  5. General systems and error handling. 

The Transfer Request Service (getNextNumber Service)

The getNextNumber service is going to be called by the host system as POST Service. This would allow the host system to send us contextual information, and for us to return a number we would expect the call to be transferred on.

Within this service we would need to do the following:

- As this is a public service - we need to secure this service with an API Key and Token. You can generate API keys using this link here.

An alternative to using the API Key and Token would be to sign the request with a X-Twilio-Signature. More details about this are found here.

 

- We then check if the credentials are valid. If they aren’t we return a 401 error back to the host system

const client = require('twilio')(key, token, { accountSid: AccountSid });
     if (!client){
       //Unauthenticated - send response as a 401
       let response = new Twilio.Response();
       response.setStatusCode(401);
       response.appendHeader('Content-Type', 'application/json');
       response.setBody({
         'error': 'Unauthorized'
       });
       callback(null, response);
     }

- Look up in the first Sync Map, (which we will call the pool numbers map), the next available free number.

- If this returns 0 or null, we need to provision a brand new number to the pool and to use this. This would save any error handling on the host side. We can look up what numbers are available for us to purchase and then purchase the first one.

let findNumber = await sync.provisionNewPoolNumber(client)
let createNumber = await sync.createNewPoolNumber(findNumber[0].phoneNumber.replace('+',''),addressSID, bundleSID,incomingFunction,newNumberName, client)

The finding and buying of a new local number is all done through the Twilio APIs.

let getNumber = await client.availablePhoneNumbers('AU')
       .local
       .list({limit: 1});
       return getNumber;

try{
       console.log('Get Local AU Numbers');
       let createNumber = await client.incomingPhoneNumbers.create({
           addressSid: addressSID,
           phoneNumber: number,
           voiceUrl:incomingFunction,
           friendlyName: newNumberName
       });
       console.log(createNumber);
       return createNumber;
   }
   catch(error){
       console.log(error);
       return error;
   }

- We need to remove this number from the “Pool Map” so this cannot be used again.

let deleteItem = await sync.deleteItem(nextNumber,TWILIO_SYNC_SID,TWILIO_SYNC_POOL_SID,client)        

- Once we have this pool number, we would use this as the key to store the data against.

- We then need to create the JSON object with the customer information in

- In the second Sync Map, (which can call the reserved map) we need to store this JSON object next to the key of the reserved pool number:

let createReservation = await sync.createReservation(context.TTL,nextNumber,CustomerNumber,CustomerDOB,TWILIO_SYNC_SID,TWILIO_SYNC_RESERVE_SID,client);             

- Once everything is stored - we then return the number to the consuming host system.

One of the design considerations we discussed is: “what happens if no-one calls this number?”. We would then be holding customer data while blocking a pool number. Neither would be released until a call was received. This might mean we could run out of pool numbers and continually buy more.

To fix this, we created a Task in TaskRouter with an expiry. This task is designed as a health check. Its purpose is to monitor the inbound call, and in the event we do not receive an inbound call in the allowed time (for example, 10 seconds), then it would clean up the records and release the number back to the pool.

let createPoolNumberTTL = await sync.createPoolNumberTTL(context.TTL, context.TaskRouterWorkSpace,context.TaskRouterWorkFlow, findNumber[0].phoneNumber.replace('+',''),TWILIO_SYNC_SID,TWILIO_SYNC_RESERVE_SID,client);

For this we set up TaskRouter to receive tasks.

try{
       let taskRouterSID = await client.taskrouter.workspaces(TaskRouterWorkSpace)
       .tasks
       .create({timeout: TTL, attributes: JSON.stringify({
           TWILIO_SYNC_SID: TWILIO_SYNC_SID,
           TWILIO_SYNC_RESERVE_SID: TWILIO_SYNC_RESERVE_SID,
           TwilioPoolNumber: TwilioPoolNumber
        }), workflowSid: TaskRouterWorkFlow});
       console.log(taskRouterSID);
       return taskRouterSID;
   }
   catch(error){
       console.log(error);
       return error;
   }

When the task expires - we have a webhook running on TaskRouter to receive the task cancelled event and kick off the clean up task. The task would hold the incoming Pool Number and the time when we stored the record. The task expiry would either still see the number in the reserved map and need to remove it, or it would find nothing – which meant everything worked okay.

if (event.EventType==="task.canceled"){
     //console.log('Task Canceled');
     const accountSid = context.ACCOUNT_SID;
     const authToken = context.AUTH_TOKEN;
     const client = require('twilio')(accountSid, authToken);
    
     const obj = JSON.parse(event.TaskAttributes);
     let TwilioPoolNumber = obj.TwilioPoolNumber;
     let TWILIO_SYNC_SID = obj.TWILIO_SYNC_SID;
     let TWILIO_SYNC_POOL_SID = context.TWILIO_SYNC_POOL_SID;
     let TWILIO_SYNC_RESERVE_SID = obj.TWILIO_SYNC_RESERVE_SID;
     let sync_map_item_get = await sync.fetchItem(TwilioPoolNumber,TWILIO_SYNC_SID,TWILIO_SYNC_RESERVE_SID,client)
     if (sync_map_item_get){
       d = new Date();
       let utc = new Date(d.getTime() - (context.TTL));
       if (sync_map_item_get.dateCreated <= utc){
         let sync_map_item_delete = await sync.deleteItem(TwilioPoolNumber,TWILIO_SYNC_SID,TWILIO_SYNC_RESERVE_SID,client)
         let sync_map_item_release = await sync.createItem(TwilioPoolNumber,TWILIO_SYNC_SID,TWILIO_SYNC_POOL_SID,client)
         callback(null,sync_map_item_release);
       }
       else{
         callback(null,'Still Valid');
       }
     }else{
       callback(null,'No Reservation there');
     }
   }

The Call Forward Service

The Call Forward Service we have attached to the incoming pool number. This can be set as a webhook on each Twilio Phone Number resource.

To do this navigate to your Phone Numbers Page.

You will need to take into account the regulatory governance for each country you wish to buy a number. More details can be found here.

On the phone number, scroll down to the Voice & Fax section and choose “Functions” from the drop down box, then choose the function you deployed called callForwardTo.

When a call is received, the call will initiate this Twilio Function as the programmatic resource to use.

Configuring a Function webhook for PSTN handoff with context

When a call is received on this number there are three things we need to do:

  1. Retrieve the Stored records associated with this key in the reserved pool map
  2. Release this number back to the pool straight away. We can do this straight away because each call is like a single independent thread. Twilio can handle multiple calls on the same number. Each call is labelled with a unique identifier called the CALL SID. Once we retrieve the stored data and associate it with this call, we can release the number to be used again.
  3. We need to forward this call on in context to the application or IVR awaiting this information.
Retrieve Stored Records

This is straightforward to do using the Twilio Sync APIs.


let sync_map_item_get = await sync.fetchItem(TwilioPoolNumber,TWILIO_SYNC_SID,TWILIO_SYNC_RESERVE_SID,client)
 
try {
       await client.sync.services(TWILIO_SYNC_SID)
           .syncMaps(TWILIO_SYNC_MAP_SID)
           .syncMapItems(item)
           .fetch()
           .then(sync_map_item => {
               console.log('Sync Found');
               result=sync_map_item;
           })
           .catch((error) =>{
               console.log(error);
               result= false;
           });
           return result;
      
   } catch (error) {
       if(error.status === 404){
           return 'No Mapping Found'
       }
       else{
           console.log(error);
           return error
       }
   }

Release the number back to the pool

To do this we need to do two things.

        1 - Delete the record from the Reserved Map

        2 - Place the number back into the Pool Map

//If we successfully got the pool details... lets remove it.
       let sync_map_item_delete = await sync.deleteItem(TwilioPoolNumber,TWILIO_SYNC_SID,TWILIO_SYNC_RESERVE_SID,client)
       // console.log(sync_map_item_delete);
       //Add the number back to the other pool so it can be reused.
       let sync_map_item_release = await sync.createItem(TwilioPoolNumber,TWILIO_SYNC_SID,TWILIO_SYNC_POOL_SID,client)

DeleteItem

try {
       await client.sync.services(TWILIO_SYNC_SID)
       .syncMaps(TWILIO_SYNC_MAP_SID)
       .syncMapItems(item)
       .remove().then(() => {
           return 'removed';
       }).catch((error) =>{
           console.log(error);
           return error;
       });
   } catch (error) {
           console.log(error);
           return error;
   }

CreateItem

try{
       let result = await client.sync.services(TWILIO_SYNC_SID)
          .syncMaps(TWILIO_SYNC_MAP_SID)
          .syncMapItems
          .create({key: key, data: { "status": "AVAILABLE"}});
          console.log(result);
          return result;
          //  return '{"key":"' + key +',"message":"The Key Has Been Created Successfully","code":200}'
   }
   catch(error){
       if(error.status === 54208){
           console.log(error);
           return "item already exists"
       }
       else if(error.code === 54208){
           console.log('Number has already been reserved');
           return error;
       }
       else{
           console.log(error);
           return error;
       }
   }

Forward Calls On

As this is a Twilio Voice Call, we can redirect the call to where we want it to go. This could be another Studio Flow, for example, or this could be another application entirely.

twiml.redirect(
       {
           method: "POST",
       },
     `${URL}?CustomerCLI=${sync_map_item_get.data.CustomerNumber}&CustomerDOB=${sync_map_item_get.data.CustomerDOB}`
       );

In this occasion, we are going to forward this onto another endpoint wrapping the contextual stored data with it.

Other Considerations

One thing we have to bear in mind is having context is the happy path. In the event we receive a call on this number and we do not have any data associated with it, we need to be aware of it and handle this accordingly.

For this occurrence, we are still going to forward this on to the endpoint but with no data attached. The end point will handle this as an unauthenticated caller.

console.log(' Number not found - passing with no  Customer CLI');
       twiml.redirect(
       {
           method: "POST",
       },
       `${URL}`
       );

However, we could also play a message to the user before we did this – or even end the call.

const say = twiml.say({
               voice: 'Polly.Nicole'
           }, 'Customer Number is');
           say.sayAs({'interpret-as':'digits'}, sync_map_item_get.data.CustomerNumber);     
           say.w('This number is not found');

How to Deploy

In the repository that you cloned, you will find an env.example file to fill in. This will contain the Account SID, Auth Token and the various services you will need to set up. As a checklist:

  1. Twilio TaskRouter
  2. Twilio Sync and 2 x Sync Maps
  3. Twilio Phone Number, Address Regulatory Info, and Bundle
  4. Twilio Serverless functions.

In order to deploy this code please use the Twilio CLI:

  Twilio serverless:deploy --service-name <<ENTER SERVICE NAME>>

I also like using Twilio-Run, which is part of the Twilio Labs project built on Twilio Serverless. You can check this out here.

Testing

In the environment variables, if you set DEBUG = True and deploy using the Serverless API, you will be able to test this using Postman.

Testing contextual PSTN transfers using Twilio and Postman
  1. Enter the URL as a POST Request with Key, Token, CustomerNumber and CustomerDOB.
  2. You will receive a number back to call.
  3. When you call this number you should hear the details you entered.

If you would like to extend this functionality further you can pass the TWIML Redirect onto other things such as a Twilio Studio Flow. Details of how to do this can be seen here. This can be changed on line 47 of the callForwardTo Twilio Function.

Bonus Easter Egg

Inside of the Twilio Assets folder is also a call stats dashboard. You can monitor how many calls you receive and their duration for testing and other purposes.

In the git repo you will find an assets folder. Please change line 95 of the CallDashboard.html to point to your Twilio Service Domain. You can find this domain name here under “Service Details”

Enter this into your browser: https://<<SERVICE-NAME>>.twil.io/CallDashboard.html

Building contextual call transfers across the PSTN using our signalling app

We demonstrated how you can create a bridge between two voice platforms to pass context between two calls across the PSTN network where SIP is not available.

We have also demonstrated how to use Twilio Functions and Twilio Sync to retrieve data and store data close to the Twilio Platform.

TaskRouter provides an orchestration framework to create tasks for us to monitor the health of the call and the data. We could extend some more advanced logic and routing rules to handle different behaviours also.

Now that you’ve seen how to store context around incoming calls using Twilio Sync and to retrieve this to forward the call on, there are other ways you can now extend this capability. I would encourage you to look at Twilio Studio to advance the call flow as well as Taskrouter for skills based routing with Twilio Flex.

I would be happy to hear how you would make this better or any challenges you have with the code.

Mike Meisels is an Enterprise Account Executive on the Australia Team. Mike has a background in developing and implementing solution architecture. Mike joined the dark side 4 years ago but still likes to keep his hands dirty with developing solutions for Not For Profits and other organizations. Mike hides in his study away from his 5 daughters, 3 cats, and 2 dogs. Active on LinkedIn, he can be tracked down here.