Build a customer interaction workflow using Call Automation

Important

This feature of Azure Communication Services is currently in preview.

Preview APIs and SDKs are provided without a service-level agreement. We recommend that you don't use them for production workloads. Some features might not be supported, or they might have constrained capabilities.

For more information, review Supplemental Terms of Use for Microsoft Azure Previews.

In this quickstart, you'll learn how to build an application that uses the Azure Communication Services Call Automation SDK to handle the following scenario:

  • handling the IncomingCall event from Event Grid
  • answering a call
  • playing an audio file and recognizing input(DTMF) from caller
  • adding a communication user to the call such as a customer service agent who uses a web application built using Calling SDKs to connect to Azure Communication Services

Sample code

You can download the sample app from GitHub.

Prerequisites

  • An Azure account with an active subscription.
  • Azure Communication Services resource. See Create an Azure Communication Services resource. Note the resource connection string for this quickstart by navigating to your resource selecting 'Keys' from the left side menu.
  • Acquire a phone number for your Communication Service resource or connect your carrier using Azure direct routing. Note the phone number you acquired or provisioned using Azure direct routing for use in this quickstart.
  • The latest .NET library for your operating system. .NET 6.0 or higher is recommended as this quickstart uses the minimal API feature.
  • The latest version of Visual Studio 2022 (17.4.0 or higher)
  • An audio file for the message you want to play in the call. This audio should be accessible via a url.

Create a new C# application

In the console window of your operating system, use the dotnet command to create a new web application.

    dotnet new web -n MyApplication

Install required packages

  1. Configure NuGet Package Manager to use dev feed: During the preview phase, the CallAutomation package is published to the dev feed. Configure your package manager to use the Azure SDK Dev Feed from here.

  2. Install the NuGet packages: Azure.Communication.CallAutomation and Azure.Messaging.EventGrid to your project.

dotnet add <path-to-project> package Azure.Communication.CallAutomation --prerelease
dotnet add <path-to-project> package Azure.Messaging.EventGrid

Use Visual Studio Dev Tunnels for your webhook

In this quick-start, you'll use the new Visual Studio Dev Tunnels feature to obtain a public domain name so that your local application is reachable by the Call Automation platform on the Internet. The public name is needed to receive the Event Grid IncomingCall event and Call Automation events using webhooks.

Note by default the dev tunnels are disabled in Visual Studio. To enable dev tunnels, please go to Tools, than Options and enable dev tunnels in Preview Features menu.

If you haven't already configured your workstation, be sure to follow the steps in this guide. Once configured, your workstation will acquire a public domain name automatically allowing us to use the environment variable ["VS_TUNNEL_URL"] as shown below.

Set up your Event Grid subscription to receive the IncomingCall event by reading this guide.

Update Program.cs

Using the minimal API feature in .NET 6, we can easily add an HTTP POST map and answer the call. A callback URI is required so the service knows how to contact your application for subsequent calls state events such as CallConnected and PlayCompleted.

In this code snippet, /api/incomingCall is the default route that will be used to listen for and answer incoming calls. At a later step, you'll register this url with Event Grid. Since Event Grid requires you to prove ownership of your Webhook endpoint before it starts delivering events to that endpoint, the code sample also handles this one time validation by processing SubscriptionValidationEvent. This requirement prevents a malicious user from flooding your endpoint with events. For more information, see this guide.

The code sample also illustrates how you can control the callback URI by setting your own context/ID when you answer the call. All events generated by the call will be sent to the specific route you provide when answering an inbound call and the same applies to when you place an outbound call.

using Azure.Communication;
using Azure.Communication.CallAutomation;
using Azure.Messaging;
using Azure.Messaging.EventGrid;
using Azure.Messaging.EventGrid.SystemEvents;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Nodes;

var builder = WebApplication.CreateBuilder(args);

var client = new CallAutomationClient("<resource_connection_string"); //noted from pre-requisite step
var tunnelUrl = builder.Configuration["VS_TUNNEL_URL"]; // Visual Studio Dev Tunnel's dynamic FQDN
var mediaFileSource = new Uri("<link_to_media_file>"); //This URL should be public accessible and the file format should be WAV, 16KHz, Mono.
var applicationPhoneNumber = "<phone_number_acquired_as_prerequisite>";
var phoneNumberToAddToCall = "<phone_number_to_add_to_call>"; //in E.164 format starting +...

Console.WriteLine($"Tunnel URL:{builder.Configuration["VS_TUNNEL_URL"]}"); // echo Tunnel URL to screen to configure Event Grid webhook

var app = builder.Build();

app.MapPost("/api/incomingCall", async (
    [FromBody] EventGridEvent[] eventGridEvents) =>
{
    foreach (var eventGridEvent in eventGridEvents)
    {
        if (eventGridEvent.TryGetSystemEventData(out object eventData))
        {
            // Handle the webhook subscription validation event.
            if (eventData is SubscriptionValidationEventData subscriptionValidationEventData)
            {
                var responseData = new SubscriptionValidationResponse
                {
                    ValidationResponse = subscriptionValidationEventData.ValidationCode
                };
                return Results.Ok(responseData);
            }
        }
        var jsonObject = JsonNode.Parse(eventGridEvent.Data).AsObject();
        var callerId = (string)(jsonObject["from"]["rawId"]);
        var incomingCallContext = (string)jsonObject["incomingCallContext"];
        var callbackUri = new Uri(tunnelUrl + $"api/calls/{Guid.NewGuid()}?callerId={callerId}");

        AnswerCallResult answerCallResult = await client.AnswerCallAsync(incomingCallContext, callbackUri);
    }
    return Results.Ok();
});

app.MapPost("/api/calls/{contextId}", async (
    [FromBody] CloudEvent[] cloudEvents,
    [FromRoute] string contextId,
    [Required] string callerId) =>
{
    foreach (var cloudEvent in cloudEvents)
    {
        CallAutomationEventBase @event = CallAutomationEventParser.Parse(cloudEvent);
        if (@event is CallConnected)
        {
            // play audio then recognize 3-digit DTMF input with pound (#) stop tone
            var recognizeOptions =
                new CallMediaRecognizeDtmfOptions(CommunicationIdentifier.FromRawId(callerId), 3)
                {
                    InterruptPrompt = true,
                    InterToneTimeout = TimeSpan.FromSeconds(10),
                    InitialSilenceTimeout = TimeSpan.FromSeconds(5),
                    Prompt = new FileSource(mediaFileSource),
                    StopTones = new[] { DtmfTone.Pound },
                    OperationContext = "MainMenu"
                };
            await client.GetCallConnection(@event.CallConnectionId)
                .GetCallMedia()
                .StartRecognizingAsync(recognizeOptions);
        }
        if (@event is RecognizeCompleted { OperationContext: "MainMenu" })
        {
            // this RecognizeCompleted correlates to the previous action as per the OperationContext value
            var addThisPerson = new PhoneNumberIdentifier(phoneNumberToAddToCall); 
            var listOfPersonToBeAdded = new List<CommunicationIdentifier>(); 
            listOfPersonToBeAdded.Add(addThisPerson); 
            var addParticipantsOption = new AddParticipantsOptions(listOfPersonToBeAdded); 
            addParticipantsOption.SourceCallerId = new PhoneNumberIdentifier(applicationPhoneNumber);
            AddParticipantsResult result = await client.GetCallConnection(@event.CallConnectionId).AddParticipantsAsync(addParticipantsOption);
        }
    }
    return Results.Ok();
}).Produces(StatusCodes.Status200OK);

app.Run();

Replace the placeholders with the actual values in lines 12-16. In your production code, we recommend using Secret Manager for storing sensitive information.

Run the app

Within Visual Studio select the Run button or press F5 on your keyboard. You should have a dynamic FQDN echoed to the screen as per the above Console.WriteLine() command above. Use this FQDN to now configure your Event Grid webhook subscription to receive the inbound call.

Configure Event Grid webhook subscription

In order to receive the IncomingCall event for the inbound PSTN call, you must configure an Event Grid subscription as described in this concepts guide. The most important thing to remember is that an Event Grid webhook subscription must be validated against a working web server. Since you've started the project in the previous step, and you have a public FQDN, set the webhook address in your subscription to the Dev Tunnel obtained by Visual Studio plus the path to your POST endpoint (i.e. https://<dev_tunnel_fqdn>/api/incomingCall).

Once your webhook subscription has been validated, it will show up in the portal and you're now ready to test your application by making an inbound call.

Sample code

You can download the sample app from GitHub

Prerequisites

Create a new Java Spring application

Configure the Spring Initializr to create a new Java Spring application.

  1. Set the Project to be a Maven Project.
  2. Leave the rest as default unless you want to have your own customization.
  3. Add Spring Web to Dependencies section.
  4. Generate the application and it will be downloaded as a zip file. Unzip the file and start coding.

Install the Maven package

Configure Azure Artifacts development feed:

Since the Call Automation SDK version used in this QuickStart isn't yet available in Maven Central Repository, we need to configure an Azure Artifacts development feed, which contains the latest version of the Call Automation SDK.

Follow the instruction here for adding azure-sdk-for-java feed to your POM file.

Add Call Automation package references:

azure-communication-callautomation - Azure Communication Services Call Automation SDK package is retrieved from the Azure SDK Dev Feed configured above.

Look for the recently published version from here

And then add it to your POM file like this (using version 1.0.0-alpha.20221101.1 as example)

<dependency>
<groupId>com.azure</groupId>
<artifactId>azure-communication-callautomation</artifactId>
<version>1.0.0-alpha.20221101.1</version>
</dependency>

Add other packages’ references:

azure-messaging-eventgrid - Azure Event Grid SDK package: com.azure : azure-messaging-eventgrid. Data types from this package are used to handle Call Automation IncomingCall event received from the Event Grid.

<dependency>
    <groupId>com.azure</groupId>
    <artifactId>azure-messaging-eventgrid</artifactId>
    <version>4.11.2</version>
</dependency>

gson - Google Gson package: com.google.code.gson : gson is a serialization/deserialization library to handle conversion between Java Objects and JSON.

<dependency>
  <groupId>com.google.code.gson</groupId>
  <artifactId>gson</artifactId>
  <version>2.9.0</version>
</dependency>

Set up a public URI for the local application

In this quick-start, you'll use Ngrok tool to project a public URI to the local port so that your local application can be visited by the internet. The public URI is needed to receive the Event Grid IncomingCall event and Call Automation events using webhooks.

First, determine the port of your java application. 8080 is the default endpoint of a spring boot application.

Then, install Ngrok and run Ngrok with the following command: ngrok http <port>. This command will create a public URI like https://ff2f-75-155-253-232.ngrok.io/, and it is your Ngrok Fully Qualified Domain Name(Ngrok_FQDN). Keep Ngrok running while following the rest of this quick-start.

Add Controller.java

In your project folder, create a Controller.java file and update it to handle incoming calls. A callback URI is required so the service knows how to contact your application for subsequent calls state events such as CallConnected and PlayCompleted.

In this code snippet, /api/incomingCall is the default route that will be used to listen for and answer incoming calls. At a later step, we'll register this url with Event Grid. Since Event Grid requires you to prove ownership of your Webhook endpoint before it starts delivering events to that endpoint, the code sample also handles this one time validation by processing SubscriptionValidationEvent. This requirement prevents a malicious user from flooding your endpoint with events. For more information, see this guide.

The code sample also illustrates how you can control the callback URI by setting your own context/ID when you answer the call. All events generated by the call will be sent to the specific route you provide when answering an inbound call and the same applies to when you place an outbound call.

package com.example.demo;

import com.azure.communication.callautomation.*;
import com.azure.communication.callautomation.models.*;
import com.azure.communication.callautomation.models.events.*;
import com.azure.communication.common.CommunicationIdentifier;
import com.azure.communication.common.CommunicationUserIdentifier;
import com.azure.messaging.eventgrid.EventGridEvent;
import com.azure.messaging.eventgrid.systemevents.SubscriptionValidationEventData;
import com.azure.messaging.eventgrid.systemevents.SubscriptionValidationResponse;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.util.*;
import static org.springframework.web.bind.annotation.RequestMethod.POST;

@RestController
public class ActionController {
    @Autowired
    private Environment environment;
    private CallAutomationAsyncClient client;
 
    private String connectionString = "<resource_connection_string>"; //noted from pre-requisite step
    private String callbackUri = "<public_url_generated_by_ngrok>";
    private Uri mediaFileSource = new Uri("<link_to_media_file>");
    private String applicationPhoneNumber = "<phone_number_acquired_as_prerequisite>";
    private String phoneNumberToAddToCall = "<phone_number_to_add_to_call>"; //in format of +1...

    private CallAutomationAsyncClient getCallAutomationAsyncClient() {
        if (client == null) {
            client = new CallAutomationClientBuilder()
                .connectionString(connectionString)
                .buildAsyncClient();
        }
        return client;
    }

    @RequestMapping(value = "/api/incomingCall", method = POST)
    public ResponseEntity<?> handleIncomingCall(@RequestBody(required = false) String requestBody) {
        List<EventGridEvent> eventGridEvents = EventGridEvent.fromString(requestBody);

        for (EventGridEvent eventGridEvent : eventGridEvents) {
            // Handle the subscription validation event
            if (eventGridEvent.getEventType().equals("Microsoft.EventGrid.SubscriptionValidationEvent")) {
                SubscriptionValidationEventData subscriptionValidationEventData = eventGridEvent.getData().toObject(SubscriptionValidationEventData.class);
                SubscriptionValidationResponse subscriptionValidationResponse = new SubscriptionValidationResponse()
                        .setValidationResponse(subscriptionValidationEventData.getValidationCode());
                ResponseEntity<SubscriptionValidationResponse> ret = new ResponseEntity<>(subscriptionValidationResponse, HttpStatus.OK);
                return ret;
            }

            // Answer the incoming call and pass the callbackUri where Call Automation events will be delivered
            JsonObject data = new Gson().fromJson(eventGridEvent.getData().toString(), JsonObject.class); // Extract body of the event
            String incomingCallContext = data.get("incomingCallContext").getAsString(); // Query the incoming call context info for answering
            String callerId = data.getAsJsonObject("from").get("rawId").getAsString(); // Query the id of caller for preparing the Recognize prompt.

            // Call events of this call will be sent to an url with unique id.
            String callbackUri = callbackUriBase + String.format("/api/calls/%s?callerId=%s", UUID.randomUUID(), callerId);

            AnswerCallResult answerCallResult = getCallAutomationAsyncClient().answerCall(incomingCallContext, callbackUri).block();
        }

        return new ResponseEntity<>(HttpStatus.OK);
    }

    @RequestMapping(value = "/api/calls/{contextId}", method = POST)
    public ResponseEntity<?> handleCallEvents(@RequestBody String requestBody, @PathVariable String contextId, @RequestParam(name = "callerId", required = true) String callerId) {
        List<CallAutomationEventBase> acsEvents = EventHandler.parseEventList(requestBody);

        for (CallAutomationEventBase acsEvent : acsEvents) {
            if (acsEvent instanceof CallConnected) {
                CallConnected event = (CallConnected) acsEvent;

                // Call was answered and is now established
                String callConnectionId = event.getCallConnectionId();
                CommunicationIdentifier target = CommunicationIdentifier.fromRawId(callerId);

                // Play audio then recognize 3-digit DTMF input with pound (#) stop tone
                CallMediaRecognizeDtmfOptions recognizeOptions = new CallMediaRecognizeDtmfOptions(target, 3);
                recognizeOptions.setInterToneTimeout(Duration.ofSeconds(10))
                        .setStopTones(new ArrayList<>(Arrays.asList(DtmfTone.POUND)))
                        .setInitialSilenceTimeout(Duration.ofSeconds(5))
                        .setInterruptPrompt(true)
                        .setPlayPrompt(new FileSource().setUri(mediaFileSource))
                        .setOperationContext("MainMenu");

                getCallAutomationAsyncClient().getCallConnectionAsync(callConnectionId)
                        .getCallMediaAsync()
                        .startRecognizing(recognizeOptions)
                        .block();
            } else if (acsEvent instanceof RecognizeCompleted) {
                RecognizeCompleted event = (RecognizeCompleted) acsEvent;

                // This RecognizeCompleted correlates to the previous action as per the OperationContext value
                if (event.getOperationContext().equals("MainMenu")) {
                    CallConnectionAsync callConnectionAsync = getCallAutomationAsyncClient().getCallConnectionAsync(event.getCallConnectionId());

                    // Invite other participants to the call
                    CommunicationIdentifier target = new PhoneNumberIdentifier(phoneNumberToAddToCall); 
                    List<CommunicationIdentifier> targets = new ArrayList<>(Arrays.asList(target)); 
                    AddParticipantsOptions addParticipantsOptions = new AddParticipantsOptions(targets) 
        .setSourceCallerId(new PhoneNumberIdentifier(applicationPhoneNumber));  
                    Response<AddParticipantsResult> addParticipantsResultResponse = callConnectionAsync.addParticipantsWithResponse(addParticipantsOptions).block();
                }
            }
        }
        return new ResponseEntity<>(HttpStatus.OK);
    }
}

Replace the placeholders with the actual values in lines 28-32. In your production code, we recommend using Secret Manager for storing sensitive information like this.

Run the app

To run your Java application, run maven compile, package, and execute commands.

mvn compile
mvn package
mvn exec:java -Dexec.mainClass=com.example.demo.DemoApplication -Dexec.cleanupDaemonThreads=false

Subscribe to IncomingCall event

IncomingCall is an Azure Event Grid event for notifying incoming calls to your Communication Services resource. To learn more about it, see this guide.

  1. Navigate to your resource on Azure portal and select Events from the left side menu.

  2. Select + Event Subscription to create a new subscription.

  3. Filter for Incoming Call event.

  4. Choose endpoint type as web hook and provide the public url generated for your application by ngrok. Make sure to provide the exact api route that you programmed to receive the event previously. In this case, it would be <ngrok_url>/api/incomingCall.

    Screenshot of portal page to create a new event subscription.

    If your application does not send 200Ok back to Event Grid in time, Event Grid will use exponential backoff retry to send the incoming call event again. However, an incoming call only rings for 30 seconds, and acting on a call after that will not work. To avoid retries after a call expires, we recommend setting the retry policy in the Additional Features tab as: Max Event Delivery Attempts to 2 and Event Time to Live to 1 minute. Learn more about retries here.

  5. Select create to start the creation of subscription and validation of your endpoint as mentioned previously. The subscription is ready when the provisioning status is marked as succeeded.

This subscription currently has no filters and hence all incoming calls will be sent to your application. To filter for specific phone number or a communication user, use the Filters tab.

Testing the application

  1. Place a call to the number you acquired in the Azure portal.
  2. Your Event Grid subscription to the IncomingCall should execute and call your application that will request to answer the call.
  3. When the call is connected, a CallConnected event will be sent to your application's callback url. At this point, the application will request audio to be played and to receive input from the caller.
  4. From your phone, press any three number keys, or press one number key and then # key.
  5. When the input has been received and recognized, the application will make a request to add a participant to the call.
  6. Once the added user answers, you can talk to them.

Clean up resources

If you want to clean up and remove a Communication Services subscription, you can delete the resource or resource group. Deleting the resource group also deletes any other resources associated with it. Learn more about cleaning up resources.

Next steps