diff --git a/.gitignore b/.gitignore
index c9f8a31..8cc84f2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
.idea
+.vscode
**/target
\ No newline at end of file
diff --git a/tutorials/compile.sh b/tutorials/compile.sh
index c1e6228..cdeab37 100755
--- a/tutorials/compile.sh
+++ b/tutorials/compile.sh
@@ -1,3 +1,4 @@
#!/bin/sh
(cd sms/auto-subscribe-app && mvn clean package)
+(cd voice/capture-leads-app && mvn clean package)
diff --git a/tutorials/voice/capture-leads-app/README.md b/tutorials/voice/capture-leads-app/README.md
new file mode 100644
index 0000000..fbac3b6
--- /dev/null
+++ b/tutorials/voice/capture-leads-app/README.md
@@ -0,0 +1,80 @@
+# Qualify leads application sample
+
+This directory contains samples related to Java SDK tutorials: [qualify leads](https://developers.sinch.com/docs/voice/tutorials/qualify-leads/java)
+
+## DISCLAIMER
+
+This tutorial is based on mixing a command-line function with a server-side backend service.
+
+It is not a correct use of the CLI outside of an educational purpose.
+
+## Requirements
+
+- JDK 21 or later
+- [Maven](https://maven.apache.org/)
+- [ngrok](https://ngrok.com/docs)
+- [Sinch account](https://dashboard.sinch.com)
+
+## Usage
+
+### Configure application settings
+
+Application settings are using the SpringBoot configuration file: [`application.yaml`](src/main/resources/application.yaml) file and enable to configure:
+
+#### Required Sinch credentials
+
+Located in `credentials` section (*you can find all of the credentials you need on your [Sinch dashboard](https://dashboard.sinch.com)*):
+
+- `application-api-key`: YOUR_application_key
+- `application-api-secret`: YOUR_application_secret
+
+#### Other required values
+
+This tutorial uses other values that you should also assign:
+
+- `sinch-number`: This is the Sinch number assigned to your [Voice app](https://dashboard.sinch.com/voice/apps).
+- `sip-address`: If you are performing this tutorial with a SIP infrastructure, this is where you would enter your SIP address.
+
+#### Server port
+
+Located in `server` section:
+
+- port: The port to be used to listen to incoming requests. Default: 8090
+
+### Starting server locally
+
+Compile and run the application as server locally.
+
+```bash
+mvn spring-boot:run
+```
+
+### Use ngrok to forward request to local server
+
+Forwarding request to same `8090` port used above:
+
+*Note: The `8090` value is coming from default config and can be changed (see [Server port](#Server-port) configuration section)*
+
+```bash
+ngrok http 8090
+```
+
+ngrok output will contains output like:
+
+```shell
+ngrok (Ctrl+C to quit)
+
+...
+Forwarding https://0e64-78-117-86-140.ngrok-free.app -> http://localhost:8090
+
+```
+
+The line
+
+```shell
+Forwarding https://0e64-78-117-86-140.ngrok-free.app -> http://localhost:8090
+```
+
+Contains `https://0e64-78-117-86-140.ngrok-free.app` value.
+
+This value must be used to configure the callback URL on your [Sinch dashboard](https://dashboard.sinch.com/voice/apps)
\ No newline at end of file
diff --git a/tutorials/voice/capture-leads-app/pom.xml b/tutorials/voice/capture-leads-app/pom.xml
new file mode 100644
index 0000000..0abdbbb
--- /dev/null
+++ b/tutorials/voice/capture-leads-app/pom.xml
@@ -0,0 +1,52 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.2.5
+
+
+
+ my.company.com
+ sinch-sdk-java-tuturial-auto-subscribe
+ 0.0.1-SNAPSHOT
+ Sinch Java SDK Capture Leads Sample Application
+ Demo Project for Capturing Leads
+
+
+ [1.2.0,)
+ 21
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+ com.sinch.sdk
+ sinch-sdk-java
+ ${sinch.sdk.java.version}
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
+
diff --git a/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/Application.java b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/Application.java
new file mode 100644
index 0000000..03b399f
--- /dev/null
+++ b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/Application.java
@@ -0,0 +1,12 @@
+package com.mycompany.app;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class Application {
+
+ public static void main(String[] args) {
+ SpringApplication.run(Application.class, args);
+ }
+}
diff --git a/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/Config.java b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/Config.java
new file mode 100644
index 0000000..0401753
--- /dev/null
+++ b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/Config.java
@@ -0,0 +1,29 @@
+package com.mycompany.app;
+
+import com.sinch.sdk.SinchClient;
+import com.sinch.sdk.domains.voice.VoiceService;
+import com.sinch.sdk.models.Configuration;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+
+@org.springframework.context.annotation.Configuration
+public class Config {
+
+ @Value("${credentials.application-api-key}")
+ String applicationKey;
+
+ @Value("${credentials.application-api-secret}")
+ String applicationSecret;
+
+ @Bean
+ public VoiceService voiceService() {
+
+ var configuration =
+ Configuration.builder()
+ .setApplicationKey(applicationKey)
+ .setApplicationSecret(applicationSecret)
+ .build();
+
+ return new SinchClient(configuration).voice();
+ }
+}
diff --git a/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/voice/CLIHelper.java b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/voice/CLIHelper.java
new file mode 100644
index 0000000..cab8bbd
--- /dev/null
+++ b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/voice/CLIHelper.java
@@ -0,0 +1,94 @@
+package com.mycompany.app.voice;
+
+import com.sinch.sdk.core.utils.StringUtil;
+import com.sinch.sdk.domains.voice.CalloutsService;
+import com.sinch.sdk.domains.voice.VoiceService;
+import com.sinch.sdk.domains.voice.models.requests.CalloutRequestParametersCustom;
+import com.sinch.sdk.domains.voice.models.svaml.ActionConnectPstn;
+import com.sinch.sdk.domains.voice.models.svaml.AnsweringMachineDetection;
+import com.sinch.sdk.domains.voice.models.svaml.SVAMLControl;
+import com.sinch.sdk.models.E164PhoneNumber;
+import java.util.Scanner;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.stereotype.Component;
+
+@Component
+public class CLIHelper implements CommandLineRunner {
+
+ @Value("${sinch-number}")
+ String sinchNumber;
+
+ private final CalloutsService calloutsService;
+
+ @Autowired
+ public CLIHelper(VoiceService voiceService) {
+ this.calloutsService = voiceService.callouts();
+ }
+
+ @Override
+ public void run(String... args) {
+
+ while (true) {
+ E164PhoneNumber phoneNumber = promptPhoneNumber();
+
+ proceedCallout(phoneNumber);
+ }
+ }
+
+ void proceedCallout(E164PhoneNumber phoneNumber) {
+ var response =
+ calloutsService.custom(
+ CalloutRequestParametersCustom.builder()
+ .setIce(
+ SVAMLControl.builder()
+ .setAction(
+ ActionConnectPstn.builder()
+ .setNumber(phoneNumber)
+ .setCli(sinchNumber)
+ .setAnsweringMachineDetection(
+ AnsweringMachineDetection.builder().setEnabled(true).build())
+ .build())
+ .build())
+ .build());
+
+ echo("Callout response: '%s'", response);
+ }
+
+ private E164PhoneNumber promptPhoneNumber() {
+ String input;
+ boolean valid;
+ do {
+ input = prompt("\nEnter the phone number you want to call");
+ valid = E164PhoneNumber.validate(input);
+ if (!valid) {
+ echo("Invalid number '%s'", input);
+ }
+ } while (!valid);
+
+ return E164PhoneNumber.valueOf(input);
+ }
+
+ private String prompt(String prompt) {
+
+ String input = null;
+ Scanner scanner = new Scanner(System.in);
+
+ while (StringUtil.isEmpty(input)) {
+ System.out.println(prompt + " ([Q] to quit): ");
+ input = scanner.nextLine();
+ }
+
+ if ("Q".equalsIgnoreCase(input)) {
+ System.out.println("Quit application");
+ System.exit(0);
+ }
+
+ return input.replaceAll(" ", "");
+ }
+
+ private void echo(String text, Object... args) {
+ System.out.println(" " + String.format(text, args));
+ }
+}
diff --git a/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/voice/Controller.java b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/voice/Controller.java
new file mode 100644
index 0000000..901c920
--- /dev/null
+++ b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/voice/Controller.java
@@ -0,0 +1,81 @@
+package com.mycompany.app.voice;
+
+import com.sinch.sdk.domains.voice.VoiceService;
+import com.sinch.sdk.domains.voice.WebHooksService;
+import com.sinch.sdk.domains.voice.models.svaml.SVAMLControl;
+import com.sinch.sdk.domains.voice.models.webhooks.AnsweredCallEvent;
+import com.sinch.sdk.domains.voice.models.webhooks.PromptInputEvent;
+import java.util.Map;
+import java.util.Optional;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
+
+@RestController("Voice")
+public class Controller {
+
+ private final WebHooksService webhooks;
+ private final ServerBusinessLogic webhooksBusinessLogic;
+
+ @Autowired
+ public Controller(VoiceService voiceService, ServerBusinessLogic webhooksBusinessLogic) {
+ this.webhooks = voiceService.webhooks();
+ this.webhooksBusinessLogic = webhooksBusinessLogic;
+ }
+
+ @PostMapping(
+ value = "/VoiceEvent",
+ consumes = MediaType.APPLICATION_JSON_VALUE,
+ produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity VoiceEvent(
+ @RequestHeader Map headers, @RequestBody String body) {
+
+ validateRequest(headers, body);
+
+ // decode the request payload
+ var event = webhooks.unserializeWebhooksEvent(body);
+
+ Optional response = Optional.empty();
+
+ // let business layer process the request
+ if (event instanceof AnsweredCallEvent e) {
+ response = Optional.of(webhooksBusinessLogic.answeredCallEvent(e));
+ }
+ if (event instanceof PromptInputEvent e) {
+ response = Optional.of(webhooksBusinessLogic.promptInputEvent(e));
+ }
+
+ if (response.isEmpty()) {
+ return ResponseEntity.ok().body("");
+ }
+
+ String serializedResponse = webhooks.serializeWebhooksResponse(response.get());
+
+ return ResponseEntity.ok().body(serializedResponse);
+ }
+
+ void validateRequest(Map headers, String body) {
+
+ var validAuth =
+ webhooks.validateAuthenticatedRequest(
+ // The HTTP verb this controller is managing
+ "POST",
+ // The URI this controller is managing
+ "/VoiceEvent",
+ // request headers
+ headers,
+ // request payload body
+ body);
+
+ // token validation failed
+ if (!validAuth) {
+ throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
+ }
+ }
+}
diff --git a/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/voice/ServerBusinessLogic.java b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/voice/ServerBusinessLogic.java
new file mode 100644
index 0000000..1771e14
--- /dev/null
+++ b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/voice/ServerBusinessLogic.java
@@ -0,0 +1,167 @@
+package com.mycompany.app.voice;
+
+import com.sinch.sdk.domains.voice.models.DestinationSip;
+import com.sinch.sdk.domains.voice.models.TransportType;
+import com.sinch.sdk.domains.voice.models.svaml.ActionConnectSip;
+import com.sinch.sdk.domains.voice.models.svaml.ActionHangUp;
+import com.sinch.sdk.domains.voice.models.svaml.ActionRunMenu;
+import com.sinch.sdk.domains.voice.models.svaml.InstructionSay;
+import com.sinch.sdk.domains.voice.models.svaml.Menu;
+import com.sinch.sdk.domains.voice.models.svaml.MenuOption;
+import com.sinch.sdk.domains.voice.models.svaml.MenuOptionAction;
+import com.sinch.sdk.domains.voice.models.svaml.MenuOptionActionType;
+import com.sinch.sdk.domains.voice.models.svaml.SVAMLControl;
+import com.sinch.sdk.domains.voice.models.webhooks.AmdAnswerStatusType;
+import com.sinch.sdk.domains.voice.models.webhooks.AnsweredCallEvent;
+import com.sinch.sdk.domains.voice.models.webhooks.PromptInputEvent;
+import com.sinch.sdk.models.DualToneMultiFrequency;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+@Component("VoiceServerBusinessLogic")
+public class ServerBusinessLogic {
+
+ private final String SIP_MENU = "sip";
+
+ private final String NON_SIP_MENU = "non-sip";
+
+ @Value("${sinch-number}")
+ String sinchNumber;
+
+ @Value("${sip-address}")
+ String sipAddress;
+
+ public SVAMLControl answeredCallEvent(AnsweredCallEvent event) {
+
+ var amdResult = event.getAmd();
+
+ if (amdResult.getStatus() == AmdAnswerStatusType.MACHINE) {
+ return machineResponse();
+ }
+ if (amdResult.getStatus() == AmdAnswerStatusType.HUMAN) {
+ return humanResponse();
+ }
+ throw new IllegalStateException("Unexpected value: " + event);
+ }
+
+ public SVAMLControl promptInputEvent(PromptInputEvent event) {
+ var menuResult = event.getMenuResult();
+
+ if (SIP_MENU.equals(menuResult.getValue())) {
+ return sipResponse();
+ }
+ if (NON_SIP_MENU.equals(menuResult.getValue())) {
+ return nonSipResponse();
+ }
+ return defaultResponse();
+ }
+
+ private SVAMLControl sipResponse() {
+
+ String instruction =
+ "Thanks for agreeing to speak to one of our sales reps! We'll now connect your call.";
+
+ return SVAMLControl.builder()
+ .setAction(
+ ActionConnectSip.builder()
+ .setDestination(DestinationSip.valueOf(sipAddress))
+ .setCli(sinchNumber)
+ .setTransport(TransportType.TLS)
+ .build())
+ .setInstructions(
+ Collections.singletonList(InstructionSay.builder().setText(instruction).build()))
+ .build();
+ }
+
+ private SVAMLControl nonSipResponse() {
+
+ String instruction =
+ "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 https://developers.sinch.com for more great"
+ + " tutorials.";
+
+ return SVAMLControl.builder()
+ .setAction(ActionHangUp.builder().build())
+ .setInstructions(
+ Collections.singletonList(InstructionSay.builder().setText(instruction).build()))
+ .build();
+ }
+
+ private SVAMLControl defaultResponse() {
+
+ String instruction = "Thank you for trying our tutorial! This call will now end.";
+
+ return SVAMLControl.builder()
+ .setAction(ActionHangUp.builder().build())
+ .setInstructions(
+ Collections.singletonList(InstructionSay.builder().setText(instruction).build()))
+ .build();
+ }
+
+ private SVAMLControl humanResponse() {
+
+ String SIP_MENU_OPTION = "1";
+ String NON_SIP_MENU_OPTION = "2";
+
+ String mainPrompt =
+ String.format(
+ "#tts[Hi, you awesome person! Press '%s' if you have performed this tutorial using a"
+ + " sip infrastructure. Press '%s' if you have not used a sip infrastructure. Press"
+ + " any other digit to end this call.]",
+ SIP_MENU_OPTION, NON_SIP_MENU_OPTION);
+
+ String repeatPrompt =
+ String.format(
+ "#tts[Again, simply press '%s' if you have used sip, press '%s' if you have not, or"
+ + " press any other digit to end this call.]",
+ SIP_MENU_OPTION, NON_SIP_MENU_OPTION);
+
+ MenuOption option1 =
+ MenuOption.builder()
+ .setDtfm(DualToneMultiFrequency.valueOf(SIP_MENU_OPTION))
+ .setAction(MenuOptionAction.from(MenuOptionActionType.RETURN, SIP_MENU))
+ .build();
+
+ MenuOption option2 =
+ MenuOption.builder()
+ .setDtfm(DualToneMultiFrequency.valueOf(NON_SIP_MENU_OPTION))
+ .setAction(MenuOptionAction.from(MenuOptionActionType.RETURN, NON_SIP_MENU))
+ .build();
+
+ Collection options = Arrays.asList(option1, option2);
+
+ return SVAMLControl.builder()
+ .setAction(
+ ActionRunMenu.builder()
+ .setBarge(false)
+ .setMenus(
+ Collections.singletonList(
+ Menu.builder()
+ .setId("main")
+ .setMainPrompt(mainPrompt)
+ .setRepeatPrompt(repeatPrompt)
+ .setRepeats(2)
+ .setOptions(options)
+ .build()))
+ .build())
+ .build();
+ }
+
+ private SVAMLControl machineResponse() {
+
+ String instruction =
+ "Hi there! We tried to reach you to speak with you about our awesome products. We will try"
+ + " again later. Bye!";
+
+ return SVAMLControl.builder()
+ .setAction(ActionHangUp.builder().build())
+ .setInstructions(
+ Collections.singletonList(InstructionSay.builder().setText(instruction).build()))
+ .build();
+ }
+}
diff --git a/tutorials/voice/capture-leads-app/src/main/resources/application.yaml b/tutorials/voice/capture-leads-app/src/main/resources/application.yaml
new file mode 100644
index 0000000..54512e2
--- /dev/null
+++ b/tutorials/voice/capture-leads-app/src/main/resources/application.yaml
@@ -0,0 +1,16 @@
+# springboot related config file
+
+logging:
+ level:
+ com: INFO
+
+server:
+ port: 8090
+
+credentials:
+ application-api-key:
+ application-api-secret:
+
+# see README.md, "Required values" section for details about these settings
+sinch-number:
+sip-address: