Automated Survey with Java and Servlets

January 10, 2017
Written by
Jose Oliveros
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by
Paul Kamp
Twilion
Hector Ortega
Contributor
Opinions expressed by Twilio contributors are their own
Orlando Hidalgo
Contributor
Opinions expressed by Twilio contributors are their own
Kat King
Twilion

automated-survey-java-servlets

Have you ever wondered how to create an automated survey that can be answered over phone or SMS?

This tutorial will show how to do it using the Twilio API.

Here's how it works at a high level

  1. The end user calls or sends an SMS to the survey phone number.
  2. Twilio gets the call or text and makes an HTTP request to your application asking for instructions on how to respond.
  3. Your web application instructs Twilio (using TwiML) to Gather or Record user input over the phone, or prompt for text input with Message if the survey is taken via SMS.
  4. After each question, Twilio makes another request to your server with the user's input, which your application stores in its database.
  5. After storing the answer, our server will instruct Twilio to Redirect the user to the next question or finish the survey.

Editor: this is a migrated tutorial. Clone the original code from https://github.com/TwilioDevEd/automated-survey-servlets/

package com.twilio.automatedsurvey.servlets;

import com.google.inject.Inject;
import com.google.inject.Singleton;
import com.twilio.automatedsurvey.servlets.twimlquestions.AbstractTwiMLQuestionFactory;
import com.twilio.automatedsurvey.survey.Question;
import com.twilio.automatedsurvey.survey.Survey;
import com.twilio.automatedsurvey.survey.SurveyRepository;
import com.twilio.twiml.TwiML;
import com.twilio.twiml.TwiMLException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Optional;

@Singleton
public class QuestionServlet extends HttpServlet {

    private static Logger LOGGER = LoggerFactory.getLogger(QuestionServlet.class);

    private SurveyRepository surveyRepository;
    private ResponseWriter responseWriter;

    @Inject
    public QuestionServlet(SurveyRepository surveyRepository, ResponseWriter responseWriter) {
        this.surveyRepository = surveyRepository;
        this.responseWriter = responseWriter;
    }

    @Override
    public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        try {
            Long surveyId = Long.parseLong(request.getParameter("survey"));
            String questionId = request.getParameter("question");


            Optional<Survey> survey = surveyRepository.find(surveyId);
            Optional<Question> question;

            if (questionId == null) {
                question = survey.flatMap((Survey s) -> s.getFirstQuestion());
            } else {
                Long parsedQuestionId = Long.parseLong(questionId);
                question = survey.flatMap((Survey s) -> s.questionById(parsedQuestionId));
            }

            AbstractTwiMLQuestionFactory factory = AbstractTwiMLQuestionFactory.getInstance(request);

            TwiML twiMLResponse = question.map((Question q) -> factory.build(surveyId, q))
                    .orElseThrow(() -> new RuntimeException(String.format("Survey/question %s/%s not found",
                            surveyId, questionId)));

            HttpSession session = request.getSession(true);
            session.setAttribute("lastSurvey", surveyId);
            session.setAttribute("lastQuestion", question.get().getId());
            String toXml = twiMLResponse.toXml();

            LOGGER.info("response: {}", toXml);
            responseWriter.writeIn(response, toXml);
        } catch (TwiMLException e) {
            e.printStackTrace();
        }
    }

}

Creating a Survey

In order to perform an automated survey, we first need to set up the survey questions. For your convenience, this application's repository already includes one survey. If the database is configured correctly every time that the app receives a request for a survey, it will create a new survey registration.

You can modify the survey questions by editing the survey.json file located in the root of the repository and re-running the app.

package com.twilio.automatedsurvey.servlets;

import com.google.inject.Inject;
import com.google.inject.persist.Transactional;
import com.twilio.automatedsurvey.survey.Question;
import com.twilio.automatedsurvey.survey.Survey;
import com.twilio.automatedsurvey.survey.SurveyRepository;
import com.twilio.automatedsurvey.survey.loader.SurveyLoader;
import com.twilio.twiml.MessagingResponse;
import com.twilio.http.HttpMethod;
import com.twilio.twiml.messaging.Body;
import com.twilio.twiml.messaging.Message;
import com.twilio.twiml.messaging.Redirect;
import com.twilio.twiml.voice.Say;
import com.twilio.twiml.TwiML;
import com.twilio.twiml.TwiMLException;
import com.twilio.twiml.VoiceResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.inject.Singleton;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Optional;

@Singleton
public class SurveyServlet extends HttpServlet {


    private static Logger LOGGER = LoggerFactory.getLogger(SurveyServlet.class);

    private SurveyRepository surveyRepo;
    private ResponseWriter responseWriter;

    @Inject
    public SurveyServlet(SurveyRepository surveyRepo, ResponseWriter responseWriter) {
        this.surveyRepo = surveyRepo;
        this.responseWriter = responseWriter;
    }

    @Override
    public void doGet(HttpServletRequest request, HttpServletResponse response) {
        HttpSession session = request.getSession(true);

        try {
            if (isSmsAnswer(request)) {
                String survey = session.getAttribute("lastSurvey").toString();
                String lastQuestion = session.getAttribute("lastQuestion").toString();

                redirectToAnswerEndpoint(response, survey, lastQuestion);
            } else {
                Survey newSurvey = createSurveyInstance();

                String message = String.format("Welcome to the %s survey", newSurvey.getTitle());
                URI redirectUrl = new URI(String.format("question?survey=%s", newSurvey.getId()));
                if (isSms(request)) {
                    MessagingResponse messagingResponse = new MessagingResponse.Builder()
                            .message(new Message.Builder()
                                    .body(new Body.Builder(message).build())
                                    .build())
                            .redirect(new Redirect.Builder(redirectUrl)
                                    .method(HttpMethod.GET)
                                    .build()
                            )
                            .build();
                    String toXml = messagingResponse.toXml();
                    LOGGER.info("response: {}", toXml);
                    responseWriter.writeIn(response, toXml);
                } else {
                    VoiceResponse voiceResponse = new VoiceResponse.Builder()
                            .say(new Say.Builder(message).build())
                            .redirect(new com.twilio.twiml.voice.Redirect
                                    .Builder(redirectUrl)
                                    .method(HttpMethod.GET)
                                    .build()
                            )
                            .build();

                    String toXml = voiceResponse.toXml();
                    LOGGER.info("response: {}", toXml);
                    responseWriter.writeIn(response, toXml);
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Transactional
    private Survey createSurveyInstance() {
        SurveyLoader loader = new SurveyLoader("survey.json");
        return surveyRepo.add(loader.load());
    }

    private void redirectToAnswerEndpoint(HttpServletResponse response, String survey, String lastQuestion) throws TwiMLException, IOException, URISyntaxException {
        URI redirectUri = new URI(String.format("survey?survey=%s&question=%s", survey, lastQuestion));
        MessagingResponse messagingResponse = new MessagingResponse.Builder()
                .redirect(new Redirect.Builder(redirectUri)
                        .method(HttpMethod.POST)
                        .build()
                )
                .build();
        this.responseWriter.writeIn(response, messagingResponse.toXml());
    }

    private boolean isSmsAnswer(HttpServletRequest request) {
        HttpSession session = request.getSession(true);
        return session.getAttribute("lastSurvey") != null;
    }

    private boolean isSms(HttpServletRequest request) {
        return request.getParameter("MessageSid") != null;
    }

    @Override
    @Transactional
    public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
        try {
            Long surveyId = Long.parseLong(request.getParameter("survey"));

            Survey survey = surveyRepo.find(surveyId).orElseThrow(() -> new RuntimeException("Survey was not found"));

            Question answeredQuestion = isSms(request) ? survey.answerSMS(request.getParameterMap()) :
                    survey.answerCall(request.getParameterMap());

            surveyRepo.update(survey);

            Optional<Question> nextQuestion = survey.getNextQuestion(answeredQuestion);

            TwiML twiMLResponse = nextQuestion.map((Question q) -> buildRedirectTwiMLMessage(surveyId, q, request))
                    .orElse(buildThankYouTwiMLResponse(survey.getTitle(), request));


            String toXml = twiMLResponse.toXml();
            LOGGER.info("response: {}", toXml);
            responseWriter.writeIn(response, toXml);
        } catch (TwiMLException e) {
            throw new RuntimeException(e);
        }
    }

    private TwiML buildThankYouTwiMLResponse(String surveyTitle, HttpServletRequest request) {
        final String realMessage = String.format("Thank you for taking the %s survey. Good bye.", surveyTitle);
        if (isSms(request)) {
            return new MessagingResponse.Builder()
                    .message(new Message.Builder()
                                        .body(new Body.Builder(realMessage).build())
                                        .build())
                    .build();
        } else {
            return new VoiceResponse.Builder()
                    .say(new Say.Builder(realMessage).build())
                    .build();
        }
    }

    private TwiML buildRedirectTwiMLMessage(Long surveyId, Question q, HttpServletRequest request) {
        final URI url;
        try {
            url = new URI(String.format("question?survey=%s&question=%s", surveyId, q.getId()));
        } catch (URISyntaxException e) {
            throw new RuntimeException(e);
        }

        final Redirect messagingRedirect = new Redirect.Builder(url)
                                                       .method(HttpMethod.GET)
                                                       .build();
        final com.twilio.twiml.voice.Redirect voiceRedirect = new com.twilio.twiml.voice.Redirect
                .Builder(url)
                .method(HttpMethod.GET)
                .build();

        if (isSms(request)) {
            return new MessagingResponse.Builder()
                    .redirect(messagingRedirect)
                    .build();
        } else {
            return new VoiceResponse.Builder()
                    .redirect(voiceRedirect)
                    .build();
        }
    }


}

We want users to take our survey, so we still need to implement the handler for SMS and calls. First, let's take a moment to understand the flow of a Twilio-powered survey as an interview loop.

The Interview Loop

The user can answer your survey questions over the phone using either their phone's keypad or by voice input. After each interaction Twilio will make an HTTP request to your web application with either the string of keys the user pressed or a URL to a recording of their voice input.

For SMS surveys the user will answer questions by replying with another SMS to the Twilio number that sent the question.

It's up to the application to process, store, and respond to the user's input.

Let's dive into this flow to see how it actually works.

Configuring a Twilio Number

To initiate the interview process, we need to configure one of our Twilio numbers to send our web application an HTTP request when we get an incoming call or text.

Click on one of your numbers to configure your Voice and Message URLs to point to the /survey route using HTTP GET.

Automated Survey Webhook Setup

If you don't already have a server configured to use as your webhook, ngrok is a great tool for testing webhooks locally.

package com.twilio.automatedsurvey.servlets;

import com.google.inject.Inject;
import com.google.inject.persist.Transactional;
import com.twilio.automatedsurvey.survey.Question;
import com.twilio.automatedsurvey.survey.Survey;
import com.twilio.automatedsurvey.survey.SurveyRepository;
import com.twilio.automatedsurvey.survey.loader.SurveyLoader;
import com.twilio.twiml.MessagingResponse;
import com.twilio.http.HttpMethod;
import com.twilio.twiml.messaging.Body;
import com.twilio.twiml.messaging.Message;
import com.twilio.twiml.messaging.Redirect;
import com.twilio.twiml.voice.Say;
import com.twilio.twiml.TwiML;
import com.twilio.twiml.TwiMLException;
import com.twilio.twiml.VoiceResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.inject.Singleton;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Optional;

@Singleton
public class SurveyServlet extends HttpServlet {


    private static Logger LOGGER = LoggerFactory.getLogger(SurveyServlet.class);

    private SurveyRepository surveyRepo;
    private ResponseWriter responseWriter;

    @Inject
    public SurveyServlet(SurveyRepository surveyRepo, ResponseWriter responseWriter) {
        this.surveyRepo = surveyRepo;
        this.responseWriter = responseWriter;
    }

    @Override
    public void doGet(HttpServletRequest request, HttpServletResponse response) {
        HttpSession session = request.getSession(true);

        try {
            if (isSmsAnswer(request)) {
                String survey = session.getAttribute("lastSurvey").toString();
                String lastQuestion = session.getAttribute("lastQuestion").toString();

                redirectToAnswerEndpoint(response, survey, lastQuestion);
            } else {
                Survey newSurvey = createSurveyInstance();

                String message = String.format("Welcome to the %s survey", newSurvey.getTitle());
                URI redirectUrl = new URI(String.format("question?survey=%s", newSurvey.getId()));
                if (isSms(request)) {
                    MessagingResponse messagingResponse = new MessagingResponse.Builder()
                            .message(new Message.Builder()
                                    .body(new Body.Builder(message).build())
                                    .build())
                            .redirect(new Redirect.Builder(redirectUrl)
                                    .method(HttpMethod.GET)
                                    .build()
                            )
                            .build();
                    String toXml = messagingResponse.toXml();
                    LOGGER.info("response: {}", toXml);
                    responseWriter.writeIn(response, toXml);
                } else {
                    VoiceResponse voiceResponse = new VoiceResponse.Builder()
                            .say(new Say.Builder(message).build())
                            .redirect(new com.twilio.twiml.voice.Redirect
                                    .Builder(redirectUrl)
                                    .method(HttpMethod.GET)
                                    .build()
                            )
                            .build();

                    String toXml = voiceResponse.toXml();
                    LOGGER.info("response: {}", toXml);
                    responseWriter.writeIn(response, toXml);
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Transactional
    private Survey createSurveyInstance() {
        SurveyLoader loader = new SurveyLoader("survey.json");
        return surveyRepo.add(loader.load());
    }

    private void redirectToAnswerEndpoint(HttpServletResponse response, String survey, String lastQuestion) throws TwiMLException, IOException, URISyntaxException {
        URI redirectUri = new URI(String.format("survey?survey=%s&question=%s", survey, lastQuestion));
        MessagingResponse messagingResponse = new MessagingResponse.Builder()
                .redirect(new Redirect.Builder(redirectUri)
                        .method(HttpMethod.POST)
                        .build()
                )
                .build();
        this.responseWriter.writeIn(response, messagingResponse.toXml());
    }

    private boolean isSmsAnswer(HttpServletRequest request) {
        HttpSession session = request.getSession(true);
        return session.getAttribute("lastSurvey") != null;
    }

    private boolean isSms(HttpServletRequest request) {
        return request.getParameter("MessageSid") != null;
    }

    @Override
    @Transactional
    public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
        try {
            Long surveyId = Long.parseLong(request.getParameter("survey"));

            Survey survey = surveyRepo.find(surveyId).orElseThrow(() -> new RuntimeException("Survey was not found"));

            Question answeredQuestion = isSms(request) ? survey.answerSMS(request.getParameterMap()) :
                    survey.answerCall(request.getParameterMap());

            surveyRepo.update(survey);

            Optional<Question> nextQuestion = survey.getNextQuestion(answeredQuestion);

            TwiML twiMLResponse = nextQuestion.map((Question q) -> buildRedirectTwiMLMessage(surveyId, q, request))
                    .orElse(buildThankYouTwiMLResponse(survey.getTitle(), request));


            String toXml = twiMLResponse.toXml();
            LOGGER.info("response: {}", toXml);
            responseWriter.writeIn(response, toXml);
        } catch (TwiMLException e) {
            throw new RuntimeException(e);
        }
    }

    private TwiML buildThankYouTwiMLResponse(String surveyTitle, HttpServletRequest request) {
        final String realMessage = String.format("Thank you for taking the %s survey. Good bye.", surveyTitle);
        if (isSms(request)) {
            return new MessagingResponse.Builder()
                    .message(new Message.Builder()
                                        .body(new Body.Builder(realMessage).build())
                                        .build())
                    .build();
        } else {
            return new VoiceResponse.Builder()
                    .say(new Say.Builder(realMessage).build())
                    .build();
        }
    }

    private TwiML buildRedirectTwiMLMessage(Long surveyId, Question q, HttpServletRequest request) {
        final URI url;
        try {
            url = new URI(String.format("question?survey=%s&question=%s", surveyId, q.getId()));
        } catch (URISyntaxException e) {
            throw new RuntimeException(e);
        }

        final Redirect messagingRedirect = new Redirect.Builder(url)
                                                       .method(HttpMethod.GET)
                                                       .build();
        final com.twilio.twiml.voice.Redirect voiceRedirect = new com.twilio.twiml.voice.Redirect
                .Builder(url)
                .method(HttpMethod.GET)
                .build();

        if (isSms(request)) {
            return new MessagingResponse.Builder()
                    .redirect(messagingRedirect)
                    .build();
        } else {
            return new VoiceResponse.Builder()
                    .redirect(voiceRedirect)
                    .build();
        }
    }


}

We that we have configured our webhooks in the Twilio Console, we are ready to create our webhook endpoints.

Responding to a Twilio Request

Right after receiving a call or SMS, Twilio sends a request to the URL specified in our phone number configuration.

The endpoint will then process the request. If the incoming request is not a response to a previous question, the application will build and return a welcome message for the user. This welcome message is constructed by using a Say verb for a voice response or a Message verb if the user is taking the survey via SMS.

Our response also includes a Redirect for redirecting to the question's endpoint in order to continue with the survey flow.

package com.twilio.automatedsurvey.servlets;

import com.google.inject.Inject;
import com.google.inject.persist.Transactional;
import com.twilio.automatedsurvey.survey.Question;
import com.twilio.automatedsurvey.survey.Survey;
import com.twilio.automatedsurvey.survey.SurveyRepository;
import com.twilio.automatedsurvey.survey.loader.SurveyLoader;
import com.twilio.twiml.MessagingResponse;
import com.twilio.http.HttpMethod;
import com.twilio.twiml.messaging.Body;
import com.twilio.twiml.messaging.Message;
import com.twilio.twiml.messaging.Redirect;
import com.twilio.twiml.voice.Say;
import com.twilio.twiml.TwiML;
import com.twilio.twiml.TwiMLException;
import com.twilio.twiml.VoiceResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.inject.Singleton;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Optional;

@Singleton
public class SurveyServlet extends HttpServlet {


    private static Logger LOGGER = LoggerFactory.getLogger(SurveyServlet.class);

    private SurveyRepository surveyRepo;
    private ResponseWriter responseWriter;

    @Inject
    public SurveyServlet(SurveyRepository surveyRepo, ResponseWriter responseWriter) {
        this.surveyRepo = surveyRepo;
        this.responseWriter = responseWriter;
    }

    @Override
    public void doGet(HttpServletRequest request, HttpServletResponse response) {
        HttpSession session = request.getSession(true);

        try {
            if (isSmsAnswer(request)) {
                String survey = session.getAttribute("lastSurvey").toString();
                String lastQuestion = session.getAttribute("lastQuestion").toString();

                redirectToAnswerEndpoint(response, survey, lastQuestion);
            } else {
                Survey newSurvey = createSurveyInstance();

                String message = String.format("Welcome to the %s survey", newSurvey.getTitle());
                URI redirectUrl = new URI(String.format("question?survey=%s", newSurvey.getId()));
                if (isSms(request)) {
                    MessagingResponse messagingResponse = new MessagingResponse.Builder()
                            .message(new Message.Builder()
                                    .body(new Body.Builder(message).build())
                                    .build())
                            .redirect(new Redirect.Builder(redirectUrl)
                                    .method(HttpMethod.GET)
                                    .build()
                            )
                            .build();
                    String toXml = messagingResponse.toXml();
                    LOGGER.info("response: {}", toXml);
                    responseWriter.writeIn(response, toXml);
                } else {
                    VoiceResponse voiceResponse = new VoiceResponse.Builder()
                            .say(new Say.Builder(message).build())
                            .redirect(new com.twilio.twiml.voice.Redirect
                                    .Builder(redirectUrl)
                                    .method(HttpMethod.GET)
                                    .build()
                            )
                            .build();

                    String toXml = voiceResponse.toXml();
                    LOGGER.info("response: {}", toXml);
                    responseWriter.writeIn(response, toXml);
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Transactional
    private Survey createSurveyInstance() {
        SurveyLoader loader = new SurveyLoader("survey.json");
        return surveyRepo.add(loader.load());
    }

    private void redirectToAnswerEndpoint(HttpServletResponse response, String survey, String lastQuestion) throws TwiMLException, IOException, URISyntaxException {
        URI redirectUri = new URI(String.format("survey?survey=%s&question=%s", survey, lastQuestion));
        MessagingResponse messagingResponse = new MessagingResponse.Builder()
                .redirect(new Redirect.Builder(redirectUri)
                        .method(HttpMethod.POST)
                        .build()
                )
                .build();
        this.responseWriter.writeIn(response, messagingResponse.toXml());
    }

    private boolean isSmsAnswer(HttpServletRequest request) {
        HttpSession session = request.getSession(true);
        return session.getAttribute("lastSurvey") != null;
    }

    private boolean isSms(HttpServletRequest request) {
        return request.getParameter("MessageSid") != null;
    }

    @Override
    @Transactional
    public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
        try {
            Long surveyId = Long.parseLong(request.getParameter("survey"));

            Survey survey = surveyRepo.find(surveyId).orElseThrow(() -> new RuntimeException("Survey was not found"));

            Question answeredQuestion = isSms(request) ? survey.answerSMS(request.getParameterMap()) :
                    survey.answerCall(request.getParameterMap());

            surveyRepo.update(survey);

            Optional<Question> nextQuestion = survey.getNextQuestion(answeredQuestion);

            TwiML twiMLResponse = nextQuestion.map((Question q) -> buildRedirectTwiMLMessage(surveyId, q, request))
                    .orElse(buildThankYouTwiMLResponse(survey.getTitle(), request));


            String toXml = twiMLResponse.toXml();
            LOGGER.info("response: {}", toXml);
            responseWriter.writeIn(response, toXml);
        } catch (TwiMLException e) {
            throw new RuntimeException(e);
        }
    }

    private TwiML buildThankYouTwiMLResponse(String surveyTitle, HttpServletRequest request) {
        final String realMessage = String.format("Thank you for taking the %s survey. Good bye.", surveyTitle);
        if (isSms(request)) {
            return new MessagingResponse.Builder()
                    .message(new Message.Builder()
                                        .body(new Body.Builder(realMessage).build())
                                        .build())
                    .build();
        } else {
            return new VoiceResponse.Builder()
                    .say(new Say.Builder(realMessage).build())
                    .build();
        }
    }

    private TwiML buildRedirectTwiMLMessage(Long surveyId, Question q, HttpServletRequest request) {
        final URI url;
        try {
            url = new URI(String.format("question?survey=%s&question=%s", surveyId, q.getId()));
        } catch (URISyntaxException e) {
            throw new RuntimeException(e);
        }

        final Redirect messagingRedirect = new Redirect.Builder(url)
                                                       .method(HttpMethod.GET)
                                                       .build();
        final com.twilio.twiml.voice.Redirect voiceRedirect = new com.twilio.twiml.voice.Redirect
                .Builder(url)
                .method(HttpMethod.GET)
                .build();

        if (isSms(request)) {
            return new MessagingResponse.Builder()
                    .redirect(messagingRedirect)
                    .build();
        } else {
            return new VoiceResponse.Builder()
                    .redirect(voiceRedirect)
                    .build();
        }
    }


}

Thats how you generate TwiML responses in your webhooks. Now lets see how to store the state of the survey using a controller.

Question Controller

This endpoint returns the Question specified in the request. If no specific question is specified in the request, it will return the first question in the survey.

Each type of question and interaction (Call or SMS) will produce different instructions on how to proceed. For instance, we can record voice or gather a key press during a call, but we can't do the same for text messages.

AbstractTwiMLQuestionFactory.getInstance solves that problem. It will return a different type of factory that knows how to build responses depending on wether the request originated via SMS or a voice call.

When the user is interacting with our survey over SMS we don't have something like an ongoing call session with a well defined state. It becomes harder to know if an SMS is answering question 2 or 20, since all requests are sent to our /survey main endpoint.

To solve that, we'll store the id of the survey and question at hand in the HTTP session. We can reuse these for subsequent SMS requests.

package com.twilio.automatedsurvey.servlets;

import com.google.inject.Inject;
import com.google.inject.Singleton;
import com.twilio.automatedsurvey.servlets.twimlquestions.AbstractTwiMLQuestionFactory;
import com.twilio.automatedsurvey.survey.Question;
import com.twilio.automatedsurvey.survey.Survey;
import com.twilio.automatedsurvey.survey.SurveyRepository;
import com.twilio.twiml.TwiML;
import com.twilio.twiml.TwiMLException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Optional;

@Singleton
public class QuestionServlet extends HttpServlet {

    private static Logger LOGGER = LoggerFactory.getLogger(QuestionServlet.class);

    private SurveyRepository surveyRepository;
    private ResponseWriter responseWriter;

    @Inject
    public QuestionServlet(SurveyRepository surveyRepository, ResponseWriter responseWriter) {
        this.surveyRepository = surveyRepository;
        this.responseWriter = responseWriter;
    }

    @Override
    public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        try {
            Long surveyId = Long.parseLong(request.getParameter("survey"));
            String questionId = request.getParameter("question");


            Optional<Survey> survey = surveyRepository.find(surveyId);
            Optional<Question> question;

            if (questionId == null) {
                question = survey.flatMap((Survey s) -> s.getFirstQuestion());
            } else {
                Long parsedQuestionId = Long.parseLong(questionId);
                question = survey.flatMap((Survey s) -> s.questionById(parsedQuestionId));
            }

            AbstractTwiMLQuestionFactory factory = AbstractTwiMLQuestionFactory.getInstance(request);

            TwiML twiMLResponse = question.map((Question q) -> factory.build(surveyId, q))
                    .orElseThrow(() -> new RuntimeException(String.format("Survey/question %s/%s not found",
                            surveyId, questionId)));

            HttpSession session = request.getSession(true);
            session.setAttribute("lastSurvey", surveyId);
            session.setAttribute("lastQuestion", question.get().getId());
            String toXml = twiMLResponse.toXml();

            LOGGER.info("response: {}", toXml);
            responseWriter.writeIn(response, toXml);
        } catch (TwiMLException e) {
            e.printStackTrace();
        }
    }

}

Let's see how the response is built.

Building Our TwiML Verbs

If the survey question is numeric or boolean ("yes/no") in nature we will use the <Gather> verb to build our TwiML. However, if we expect the user to record a voice answer we want to use the <Record> verb. Both verbs take an action attribute and a method attribute.

Twilio uses both attributes to define our response's endpoint to use as a callback. This endpoint is responsible for receiving and storing the caller's answer.

During the Record verb creation, we also ask Twilio for a Transcription. Twilio will process the voice recording and extract all useful text, making a request to our response endpoint when the transcription is complete.

package com.twilio.automatedsurvey.servlets;

import com.twilio.automatedsurvey.servlets.twimlquestions.AbstractTwiMLQuestionFactory;
import com.twilio.automatedsurvey.survey.Question;
import com.twilio.http.HttpMethod;
import com.twilio.twiml.voice.Gather;
import com.twilio.twiml.voice.Pause;
import com.twilio.twiml.voice.Record;
import com.twilio.twiml.voice.Say;
import com.twilio.twiml.VoiceResponse;

public class TelephoneTwiMLQuestionFactory extends AbstractTwiMLQuestionFactory {

    @Override
    public VoiceResponse build(Long surveyId, Question question) {
        switch (question.getType()){
            case voice:
                return  buildVoiceMessage(surveyId, question,
                        "Record your answer after the beep and press the pound key when you are done.");
            case numeric:
                return buildNumericMessage(surveyId, question,
                        "For the next question select a number with the dial pad " +
                        "and then press the pound key");
            case yesno:
                return buildNumericMessage(surveyId, question,
                        "For the next question, press 1 for yes, and 0 for no. Then press the pound key.");
            default:
                throw new RuntimeException("Invalid question type");
        }
    }

    private VoiceResponse buildVoiceMessage(Long surveyId, Question question, String message) {
        return new VoiceResponse.Builder()
                .say(new Say.Builder(message).build())
                .pause(new Pause.Builder().build())
                .say(new Say.Builder(question.getBody()).build())
                .record(new Record.Builder()
                        .transcribe(true)
                        .transcribeCallback("survey?survey="+ surveyId +"&amp;question="+question.getId())
                        .action("survey?survey="+ surveyId +"&amp;question="+question.getId())
                        .method(HttpMethod.POST)
                        .maxLength(6)
                        .build()
                )
                .build();
    }

    private VoiceResponse buildNumericMessage(Long surveyId, Question question, String message) {
        return new VoiceResponse.Builder()
                .say(new Say.Builder(message).build())
                .pause(new Pause.Builder().build())
                .say(new Say.Builder(question.getBody()).build())
                .gather(new Gather.Builder()
                        .action("survey?survey=" + surveyId + "&amp;question=" + question.getId())
                        .method(HttpMethod.POST)
                        .finishOnKey("#")
                        .build()
                )
                .build();
    }
}

Now lets see what to do with the response.

Handling Responses

After the user has finished speaking and pressing keys, Twilio sends us a request explaining what happened and asking for further instructions.

In Survey.answerCall and Survey.answerSMS we extract request parameters from a Map containing the information needed to answer the question.

Recovered parameters vary according to what we asked in our survey questions:

  • Body contains the text message from an answer sent via SMS.
  • Digits contains the keys pressed for a numeric question.
  • RecordingUrl contains the URL for listening to a recorded message.
  • TranscriptionText contains the text of a voice recording transcription.

After answering a question, the SurveyController redirects to our QuestionServlet, which will ask the next question in the loop.

package com.twilio.automatedsurvey.survey;

import com.google.inject.persist.Transactional;

import javax.persistence.*;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@Entity
public class Survey {
    @Id
    @GeneratedValue
    private Long id;
    private String title;
    @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name="survey_id")
    private Set<Question> questions;

    {
        questions = new HashSet<>();
    }

    private Survey() { /* needed by the ORM */ }

    public Survey(String title) {
        this(null, title);
    }

    public Survey(Long id, String title) {
        this.id = id;
        this.title = title;
    }

    public String getTitle() {
        return title;
    }

    public Long getId() {
        return id;
    }

    public Set<Question> getQuestions() {
        return questions;
    }

    public Optional<Question> getFirstQuestion() {
        return getSortedQuestions().findFirst();
    }

    public void addQuestion(Question question) {
        questions.add(question);
    }

    private Stream<Question> getSortedQuestions() {
        Comparator<Question> questionIdComparator = (elem1, elem2) -> elem1.getId().compareTo(elem2.getId());
        return questions.stream().sorted(questionIdComparator);
    }

    @Transactional
    public Question answerCall(Map<String, String[]> parameters) {
        return answerUsing(parameters, (Question question) -> {
            String answerKey = parameters.containsKey("TranscriptionText") ?
                    "TranscriptionText" : question.getType().getAnswerKey();

            question.setAnswer(parameters.get(answerKey)[0]);
            return question;
        });
    }

    public Question answerSMS(Map<String, String[]> parameters) {
        return answerUsing(parameters, (Question q) -> {
            q.setAnswer(parameters.get("Body")[0]);
            return q;
        });
    }

    private Question answerUsing(Map<String, String[]> parameters, Function<Question, Question> extractAndApplyAnswer) {
        String questionId = parameters.get("question")[0];

        Optional<Question> question = questionById(Long.parseLong(questionId));

        return question.map(extractAndApplyAnswer)
                .orElseThrow(() -> new RuntimeException(String.format("Question %s from Survey %s not found", id, questionId)));
    }

    public Optional<Question> questionById(Long questionId) {
        return questions.stream().filter((Question question) -> question.getId().equals(questionId))
                .findFirst();
    }

    public Optional<Question> getNextQuestion(Question previousQuestion) {
        List<Question> sortedQuestions = getSortedQuestions().collect(Collectors.toList());

        int previousQuestionIndex = sortedQuestions.indexOf(previousQuestion);
        int nextQuestionIndex = previousQuestionIndex+1;

        if (nextQuestionIndex >= sortedQuestions.size()) {
            return Optional.empty();
        } else {
            return Optional.of(sortedQuestions.get(nextQuestionIndex));
        }
    }

}

Now, let's see how to visualize the results.

Displaying the Survey's Results

For this route we simply query the database using a JPA query and then display the information within a JSP page. We display a list of surveys with their respective questions and answers.

You can access this page in the application's root route.

package com.twilio.automatedsurvey.servlets;

import com.google.inject.Singleton;
import com.twilio.automatedsurvey.survey.Survey;
import com.twilio.automatedsurvey.survey.SurveyRepository;

import javax.inject.Inject;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;

@Singleton
public class IndexServlet extends HttpServlet{

    private SurveyRepository repository;

    @Inject
    public IndexServlet(SurveyRepository repository) {
        this.repository = repository;
    }

    @Override
    public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        List<Survey> surveys = repository.all();
        request.setAttribute("surveys", surveys);
        request.getRequestDispatcher("index.jsp").forward(request, response);
    }
}

That's it!

If you have configured one of your Twilio numbers to work with the application built in this tutorial you should be able to take the survey and see the results under the root route of the application. We hope you found this sample application useful.

Where to next?

If you're a Java developer working with Twilio, you might enjoy these other tutorials:

SMS and MMS Notifications

Never miss another server outage. Learn how to build a server notification system that will alert all administrators via SMS when a server outage occurs.

Click to Call

Click-to-call enables your company to convert web traffic into phone calls with the click of a button.

Did this help?

Thanks for checking this tutorial out! If you have any feedback to share with us, we'd love to hear it. Reach out to us on Twitter and let us know what you build!