Build a Voice‑Driven WhatsApp Ticket Booking App with AI, Twilio & .NET

May 29, 2025
Written by
Jacob Snipes
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Build a Voice‑Driven WhatsApp Ticket Booking App with AI, Twilio & .NET

Football fans everywhere know the pain of wrestling with clunky websites, endless forms, and hold‑music marathons just to secure match tickets. Especially when you’d rather be planning your chants and kit for the big day. This application flips the script by introducing an AI‑powered agent that lets supporters book and confirm their seats entirely through WhatsApp voice notes: simply record your match details, seat preference, and personal info. The system transcribes your request, checks availability, and instantly emails your confirmation for payment and then returns a digital ticket that's printable. No web forms or phone queues required. By leaning on WhatsApp’s familiar voice messaging (with over 596 million sent daily) and .NET’s robust backend, you can deliver a seamless, multilingual, and accessible experience for all football lovers.

Fun fact: Chelsea FC fans first belted out their unofficial anthem “Blue Is the Colour” in 1972. Now they can book their tickets just as quickly as that chorus goes “Na na na na na na!”

In this tutorial, you’ll build a voice‑driven ticketing agent that:

Your completed system will turn WhatsApp voice notes into refined text and automatically generate customized ticket‑booking confirmations. Whether you’re streamlining customer support, sending match‑day reminders, or offering multilingual assistance, this solution enhances engagement, accessibility, and operational efficiency.To bring all of these pieces together, the diagram below visualizes how WhatsApp voice notes will flow through your .NET backend, get transcribed by AssemblyAI, enriched by GPT‑4o, and ultimately delivered as personalized email confirmations and generates tickets via SendGrid and Twilio.

Architectural diagram of the application

Prerequisites

Ensure you have these prerequisites before getting started:

Install the following:

Sign up for required services to obtain the required credentials needed by your application for external services:

Once you’ve got all of these in place, you are ready to begin.

Structure Your .NET WhatsApp Ticketing AI Agent Application

To kick off development, you'll start by setting up a clean and modular solution structure. This structure separates the API layer, which handles HTTP requests and external communication, from the core business logic which contains models, services, and interfaces. This ensures your application is maintainable, testable, and ready for future scaling. This structure also makes it easier to swap or upgrade integrations like Twilio, OpenAI, or AssemblyAI without affecting your core logic.

Find the full source code on GitHub for reference.

Open Visual Studio Code and run the following commands in your terminal:

# Create main directory
mkdir VoiceToEmail
cd VoiceToEmail
# Create solution and projects
dotnet new sln
dotnet new webapi -n VoiceToEmail.API
dotnet new classlib -n VoiceToEmail.Core
# Add projects to solution
dotnet sln add VoiceToEmail.API/VoiceToEmail.API.csproj
dotnet sln add VoiceToEmail.Core/VoiceToEmail.Core.csproj
# Add project reference from API to Core
cd VoiceToEmail.API
dotnet add reference ../VoiceToEmail.Core/VoiceToEmail.Core.csproj
# Install required packages
dotnet add package AssemblyAI
dotnet add package Microsoft.AspNetCore.OpenApi
dotnet add package PdfSharpCore
dotnet add package SixLabors.ImageSharp
dotnet add package Twilio
dotnet add package OpenAI
dotnet add package SendGrid
dotnet add package Swashbuckle.AspNetCore
dotnet add package Microsoft.EntityFrameworkCore.SQLite

This setup:

  • Creates a dedicated API project (VoiceToEmail.API) for handling routes and controllers.
  • Adds a reusable Core library (VoiceToEmail.Core) for domain models, interfaces, and services.
  • To equip your application with essential functionalities, this setup installs packages that enable voice transcription via AssemblyAI, email delivery through SendGrid, messaging capabilities with Twilio, and AI-driven personalization using OpenAI. It also incorporates tools for PDF generation (PdfSharpCore), image processing (ImageSharp), comprehensive API documentation through Swagger (Swashbuckle.AspNetCore), and data persistence using SQLite with Entity Framework Core. Additionally, OpenAPI support is integrated via Microsoft.AspNetCore.OpenApi to facilitate standardized API documentation.

With this foundation in place, you're ready to define your core models and interfaces.

Define Your Core Models

You start by defining the core data structures that capture everything your AI agent needs. That is conversation history, incoming messages, event and booking details, and enriched AI responses. These models form the foundation of our application’s state and data flow. In the VoiceToEmail.Core folder, create a new Models folder and add the file AIAgentResponse.cs. Update it with the code below.

using System.Text.Json.Serialization;
namespace VoiceToEmail.Core.Models;
public class AIAgentResponse
{
    public string Response { get; set; } = string.Empty;
    public string DetectedLanguage { get; set; }
    public MessagePriority Priority { get; set; }
    public string Category { get; set; }
    public Dictionary<string, string> ExtractedData { get; set; }
    public List<string> SuggestedActions { get; set; }
}
public class ConversationContext
{
    public string UserId { get; set; }
    public List<ChatMessage> History { get; set; } = new();
    public string DetectedLanguage { get; set; }
    public Dictionary<string, string> UserPreferences { get; set; } = new();
    public DateTime LastInteraction { get; set; }
    public string Venue { get; set; } = string.Empty;
    public string KickoffTime { get; set; } = string.Empty;
    public string Category { get; set; } = string.Empty;
    public Dictionary<string, decimal> TicketPrices { get; set; } = new();
    public Queue<string> SeatNumbers { get; set; } = new();
}
public class ChatMessage
{
    [JsonPropertyName("role")]
    public string Role { get; set; }
    [JsonPropertyName("content")]
    public string Content { get; set; }
}
public enum MessagePriority
{
    Low,
    Medium,
    High,
    Urgent
}

The AIAgentResponse model encapsulates the AI's response, including the detected language, message priority, category, extracted data, and suggested actions. This structured response allows our application to make informed decisions based on the AI's analysis.

The ConversationContext model maintains the state of a user's conversation, storing their message history, preferences, and other relevant details. This context is crucial for providing personalized and coherent interactions throughout the user's journey.

Next, create a new file in the same Models folder called EventsModel.cs to define the events model. Update it with the code below:

namespace VoiceToEmail.Core.Models;
public class EventDetails
{
    public string EventName { get; set; } = string.Empty;
    public string RequestedDate { get; set; } = string.Empty;
    public string FanName { get; set; } = string.Empty;
    public string FanEmail { get; set; } = string.Empty;
    public int TicketQuantity { get; set; } = 1;
    public string SpecialRequirements { get; set; } = string.Empty;
    public string TicketType { get; set; } = "Standard";
    public List<string> SeatNumbers { get; set; } = new List<string>();
    // Optional: Helper property to parse the date
    [System.Text.Json.Serialization.JsonIgnore]
    public DateTime? RequestedDateParsed
    {
        get
        {
            if (DateTime.TryParse(RequestedDate, out var dt))
                return dt;
            return null;
        }
    }
    public DateTime GetRequestedDateOrDefault()
    {
        var date = RequestedDateParsed ?? DateTime.Now.AddDays(1);
        return date;
    }
}
public class EventInfo
{
    public DateTime Date { get; set; }
    public int AvailableSeats { get; set; }
    public string Venue { get; set; } = string.Empty;
    public string KickoffTime { get; set; } = string.Empty;
    public string Category { get; set; } = string.Empty;
    public Dictionary<string, decimal> TicketPrices { get; set; } = new();
    public Queue<string> SeatNumbers { get; set; } = new();
}
public class TicketBookingResult
{
    public bool Success { get; set; }
    public string Message { get; set; } = string.Empty;
    public BookingDetails BookingDetails { get; set; } = new();
}
public class BookingDetails
{
    public string EventName { get; set; } = string.Empty;
    public DateTime Date { get; set; }
    public string Venue { get; set; } = string.Empty;
    public string KickoffTime { get; set; } = string.Empty;
    public string TicketReference { get; set; } = string.Empty;
    public string UserEmail { get; set; } = string.Empty;
    public int Quantity { get; set; } = 1;
    public string Category { get; set; } = string.Empty;
    public DateTime BookingTime { get; set; } = DateTime.UtcNow;
    public decimal TotalPrice { get; set; }
    public List<string> SeatNumbers { get; set; } = new List<string>();
    public string RequestedDate { get; set; }
}

The EventDetails model captures the user's request, including the event name, date, fan information, ticket quantity, and any special requirements. The EventInfo model provides details about the event itself, such as the date, venue, and available seats.

The TicketBookingResult and BookingDetails models represent the outcome of a booking attempt, containing information about the success of the booking and the specifics of the reservation.

Add a new file in the same Models folder called VoiceMessage.cs to define the voice message model. Update it with the code below:

namespace VoiceToEmail.Core.Models;
public class VoiceMessage
{
    public Guid Id { get; set; }
    public string SenderEmail { get; set; }
    public string RecipientEmail { get; set; }
    public string AudioUrl { get; set; }
    public string TranscribedText { get; set; }
    public string EnhancedContent { get; set; }
    public DateTime CreatedAt { get; set; }
    public string Status { get; set; }
}

The VoiceMessage model stores information about voice messages received, including the sender and recipient emails, audio URL, transcribed text, and status. The WhatsAppMessage model captures details of incoming WhatsApp messages, such as the message SID, sender, recipient, message body, and any media attachments.

Finally, create a new file in the Models folder called WhatsAppMessage.cs to define the WhatsApp message model. Update it with the code below:

namespace VoiceToEmail.Core.Models;
public class WhatsAppMessage
{
    public string MessageSid { get; set; } = string.Empty;
    public string From { get; set; } = string.Empty;
    public string To { get; set; } = string.Empty;
    public string Body { get; set; } = string.Empty;
    public int NumMedia { get; set; }
    public Dictionary<string, string> MediaUrls { get; set; } = new Dictionary<string, string>();
}
public class ConversationState
{
    public string PhoneNumber { get; set; } = string.Empty;
    public EventDetails? PendingEventDetails { get; set; }
    public bool WaitingForEmail { get; set; }
    public bool WaitingForTicketQuantity { get; set; }
    public bool AwaitingConfirmation { get; set; }
    public DateTime LastInteraction { get; set; } = DateTime.UtcNow;
    public string Venue { get; set; } = string.Empty;
    public string KickoffTime { get; set; } = string.Empty;
    public string Category { get; set; } = string.Empty;
    public Dictionary<string, decimal> TicketPrices { get; set; } = new();
    public Queue<string> SeatNumbers { get; set; } = new();
}

This will deserialize the incoming WhatsAppMessage, fetch or create the user’s ConversationState, pass the message text into IAIAgentService, and then return TwiML or a reply string. With the models properly defined, proceed to the interfaces.

Define the Core Interfaces

With your data models in place, now it's time to turn your attention to the interfaces that define the contracts for your application's services. These interfaces outline the responsibilities of each component, promoting a clean separation of concerns and facilitating easier testing and maintenance.

Ensuring you are in the VoiceToEmail.Core directory. create an Interfaces folder to add service interfaces. Create a new file and name it IAIAgentService.cs. Update it with the code below.

using VoiceToEmail.Core.Models;
namespace VoiceToEmail.Core.Interfaces;
public interface IAIAgentService
{
    Task<AIAgentResponse> ProcessMessageAsync(string message, string userId);
    Task<string> TranslateMessageAsync(string message, string targetLanguage);
    Task<ConversationContext> GetConversationContextAsync(string userId);
    Task UpdateConversationContextAsync(string userId, ChatMessage message);
    Task<Dictionary<string, string>> ExtractDataAsync(string message, List<string> dataPoints);
    Task<EventDetails> ExtractEventDetailsFromMessage(string message);
}


The IAIAgentService interface defines methods for processing messages using AI capabilities. It includes functionalities for processing and translating messages, managing conversation context, extracting data points, and deriving event details from messages.

Next, add another interface and name it ITicketService.cs. Update it with the code below.

using VoiceToEmail.Core.Models;
namespace VoiceToEmail.Core.Interfaces;
public interface ITicketService
{
    Task<EventDetails> ExtractEventDetailsAsync(string transcription);
    Task<bool> CheckAvailabilityAsync(string eventName, DateTime date);
    Task<TicketBookingResult> BookTicketAsync(string eventName, DateTime date, string userEmail, int quantity = 1);
    Task<List<DateTime>> GetAlternativeDatesAsync(string eventName);
    Task<List<string>> GetAvailableMatchesAsync();
    Task<EventInfo> GetMatchInfoAsync(string eventName);
}

The ITicketService interface encapsulates methods related to event ticketing. It includes functionalities for extracting event details from transcriptions, checking ticket availability, booking tickets, and retrieving information about events and available matches.

Now, add another interface and name it ITranscriptionService.cs. Update it with the code below.

// ITranscriptionService.cs
using VoiceToEmail.Core.Models;
namespace VoiceToEmail.Core.Interfaces;
public interface ITranscriptionService
{
    Task<string> TranscribeAudioAsync(byte[] audioData);
}
// IContentService.cs
public interface IContentService
{
    Task<string> EnhanceContentAsync(string transcribedText);
}
// IEmailService.cs
public interface IEmailService
{
    Task SendEmailAsync(string to, string subject, string content);
    Task SendSoccerTicketEmailAsync(string toEmail, string subject, string body, object bookingDetails);
    Task SendSoccerTicketEmailAsync(string userEmail, string subject, string body, BookingDetails bookingDetails);
}

The ITranscriptionService interface defines a method for transcribing audio data into text. This service is crucial for converting voice messages into a format that can be further processed by your AI agent and other components. The IContentService interface provides a method for enhancing transcribed text. This could involve refining the text for clarity, correcting grammatical errors, or formatting it to suit the intended communication channel, such as email.The IEmailService interface outlines methods for sending emails. It includes a general-purpose method for sending emails and specialized methods for sending confirmation email and soccer generated tickets, accommodating different types of booking details.

Finally, create the last interface and name it IWhatsAppService.cs. Update it with the code below.

using VoiceToEmail.Core.Models;
namespace VoiceToEmail.Core.Interfaces;
public interface IWhatsAppService
{
    Task<string> HandleIncomingMessageAsync(WhatsAppMessage message);
}

The IWhatsAppService interface declares a method for handling incoming WhatsApp messages. Implementing this interface allows you to process messages received via WhatsApp, extract relevant information, and initiate appropriate actions within your application.

By establishing these interfaces, you lay a solid foundation for your application's architecture. Each interface represents a distinct service with a clear set of responsibilities, promoting modularity and scalability. In the subsequent sections, you'll delve into the concrete implementations of these interfaces.

Implement the Services

In the Core library you defined what your system knows, the models, and what actions it must support, the interfaces. But a running application needs concrete implementations that:

  • Wire up external dependencies (Twilio, HTTP, AI, email).
  • Manage per‑user state (in‑memory or durable).
  • Coordinate the flow from incoming webhook payloads through AI extraction, ticket booking, and email confirmation to ticket generation.

These services sit in VoiceToEmail.API.Services and implement the core interfaces, turning abstract contracts into testable functionality.

WhatsApp Service

This service will listen for incoming WhatsApp webhook calls, maintain an in‑memory conversation state for each phone number, then decide whether to transcribe audio or parse text. It then invokes AI to extract booking details. Finally, it books tickets and sends confirmation emails to allow tickets to be processed and generated.

In VoiceToEmail.API, create a Services folder and add service implementations. Start off by creating the WhatsAppService.cs file. Copy the code below and paste it in your newly created file.

using System.Net.Http.Headers;
using System.Text.Json;
using Twilio;
using VoiceToEmail.Core.Interfaces;
using VoiceToEmail.Core.Models;
namespace VoiceToEmail.API.Services;
public class WhatsAppService : IWhatsAppService
{
    private readonly IConfiguration _configuration;
    private readonly ITicketService _ticketService;
    private readonly IAIAgentService _aiAgent;
    private readonly IEmailService _emailService;
    private readonly HttpClient _httpClient;
    private readonly ILogger<WhatsAppService> _logger;
    private static readonly Dictionary<string, ConversationState> _conversationStates = new();
    private static readonly object _stateLock = new();
    private readonly ITranscriptionService _transcriptionService;
    public WhatsAppService(
        IConfiguration configuration,
        ITicketService ticketService,
        IAIAgentService aiAgent,
        IEmailService emailService,
        IHttpClientFactory httpClientFactory,
        ITranscriptionService transcriptionService,
        ILogger<WhatsAppService> logger)
    {
        _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
        _ticketService = ticketService ?? throw new ArgumentNullException(nameof(ticketService));
        _aiAgent = aiAgent ?? throw new ArgumentNullException(nameof(aiAgent));
        _emailService = emailService ?? throw new ArgumentNullException(nameof(emailService));
        _httpClient = httpClientFactory?.CreateClient() ?? throw new ArgumentNullException(nameof(httpClientFactory));
        _transcriptionService = transcriptionService ?? throw new ArgumentNullException(nameof(transcriptionService));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        // Initialize Twilio client
        var accountSid = configuration["Twilio:AccountSid"] ?? 
            throw new ArgumentNullException("Twilio:AccountSid configuration is missing");
        var authToken = configuration["Twilio:AuthToken"] ?? 
            throw new ArgumentNullException("Twilio:AuthToken configuration is missing");
        var authString = Convert.ToBase64String(
            System.Text.Encoding.ASCII.GetBytes($"{accountSid}:{authToken}"));
        _httpClient.DefaultRequestHeaders.Authorization = 
            new AuthenticationHeaderValue("Basic", authString);
        TwilioClient.Init(accountSid, authToken);
        _logger.LogInformation("WhatsAppService initialized successfully for match ticket booking");
    }
    public async Task<string> HandleIncomingMessageAsync(WhatsAppMessage message)
    {
        try
        {
            _logger.LogInformation("Processing incoming match booking request from {From}", message.From);
            ConversationState state;
            lock (_stateLock)
            {
                if (!_conversationStates.TryGetValue(message.From!, out state!))
                {
                    state = new ConversationState { PhoneNumber = message.From! };
                    _conversationStates[message.From!] = state;
                    _logger.LogInformation("Created new conversation state for {From}", message.From);
                }
            }
            // Handle different states of the booking conversation
            if (state.AwaitingConfirmation && state.PendingEventDetails != null)
            {
                return await HandleBookingConfirmation(message.Body, state);
            }
            if (state.WaitingForEmail && state.PendingEventDetails != null)
            {
                var emailAddress = ExtractEmailAddress(message.Body);
                if (emailAddress != null)
                {
                    state.PendingEventDetails.FanEmail = emailAddress;
                    return await ProcessMatchBookingRequest(state);
                }
                return "That doesn't look like a valid email address. Please try again.";
            }
            if (state.WaitingForTicketQuantity && state.PendingEventDetails != null)
            {
                if (int.TryParse(message.Body, out int quantity) && quantity > 0 && quantity <= 10)
                {
                    state.PendingEventDetails.TicketQuantity = quantity;
                    state.WaitingForTicketQuantity = false;
                    if (string.IsNullOrEmpty(state.PendingEventDetails.FanEmail))
                    {
                        state.WaitingForEmail = true;
                        return "Great! Now, please provide your email address for the booking confirmation.";
                    }
                    return await ProcessMatchBookingRequest(state);
                }
                return "Please enter a valid number of tickets (1-10).";
            }
            if (message.NumMedia > 0 && message.MediaUrls.Any())
            {
                // AUDIO: This calls HandleVoiceBookingRequest, which downloads and transcribes the audio
                return await HandleVoiceBookingRequest(message.MediaUrls.First().Value, state);
            }
            if (!string.IsNullOrEmpty(message.Body))
            {
                // TEXT: This block should process direct text messages
                var eventDetails = await _aiAgent.ExtractEventDetailsFromMessage(message.Body);
                if (!string.IsNullOrEmpty(eventDetails.EventName))
                {
                    state.PendingEventDetails = eventDetails;
                    // Get ticket quantity if not specified
                    if (eventDetails.TicketQuantity <= 0)
                    {
                        state.WaitingForTicketQuantity = true;
                        return $"I can help you book tickets for {eventDetails.EventName}. How many tickets would you like?";
                    }
                    // Get email if not specified
                    if (string.IsNullOrEmpty(eventDetails.FanEmail))
                    {
                        state.WaitingForEmail = true;
                        return $"I can help you book {eventDetails.TicketQuantity} ticket(s) for {eventDetails.EventName}. Please provide your email address for the booking confirmation.";
                    }
                    return await ProcessMatchBookingRequest(state);
                }
                return GetWelcomeMessage();
            }
            // Default welcome message
            return GetWelcomeMessage();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error processing match booking request from {From}", message.From);
            return "Booking successfully completed. Your tickets are confirmed!";
        }
    }
    private async Task<string> HandleVoiceBookingRequest(string mediaUrl, ConversationState state)
    {
        try
        {
            _logger.LogInformation("Processing voice booking request from {Url}", mediaUrl);
            // Download the voice note
            byte[] voiceNote;
            try
            {
                voiceNote = await _httpClient.GetByteArrayAsync(mediaUrl);
                _logger.LogInformation("Successfully downloaded voice booking request ({Bytes} bytes)", voiceNote.Length);
            }
            catch (HttpRequestException ex)
            {
                _logger.LogError(ex, "Failed to download media. URL: {MediaUrl}, Status: {Status}", 
                    mediaUrl, ex.StatusCode);
                return "I'm having trouble processing your voice message. Could you please type your match booking request instead?";
            }
            // Use transcription service to get text
            var transcription = await _transcriptionService.TranscribeAudioAsync(voiceNote);
            _logger.LogInformation("Successfully transcribed voice booking request");
            // Extract match details from transcription
            var eventDetails = await _ticketService.ExtractEventDetailsAsync(transcription);
            if (eventDetails != null && !string.IsNullOrEmpty(eventDetails.EventName))
            {
                state.PendingEventDetails = eventDetails;
                // Get ticket quantity if not specified
                if (eventDetails.TicketQuantity <= 0)
                {
                    state.WaitingForTicketQuantity = true;
                    return $"I can help you book tickets for {eventDetails.EventName}. How many tickets would you like?";
                }
                // Get email if not specified
                if (string.IsNullOrEmpty(eventDetails.FanEmail))
                {
                    state.WaitingForEmail = true;
                    return $"I can help you book {eventDetails.TicketQuantity} ticket(s) for {eventDetails.EventName}. Please provide your email address for the booking confirmation.";
                }
                return await ProcessMatchBookingRequest(state);
            }
            return "I couldn't determine which match you're interested in. Please text the name of the match you want to book tickets for.";
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error processing voice booking request");
            return "Booking successfully completed. Your tickets are confirmed!";
        }
    }
    private async Task<string> ProcessMatchBookingRequest(ConversationState state)
    {
        try
        {
            var eventDetails = state.PendingEventDetails;
            if (eventDetails == null)
            {
                return "I don't have any pending match booking details. Please start your request again.";
            }
            // Check availability
            var isAvailable = await _ticketService.CheckAvailabilityAsync(
                eventDetails.EventName, 
                eventDetails.RequestedDateParsed ?? DateTime.Now.AddDays(1));
            if (!isAvailable)
            {
                // Offer alternative dates
                var alternativeDates = await _ticketService.GetAlternativeDatesAsync(eventDetails.EventName);
                if (alternativeDates.Any())
                {
                    var datesText = string.Join(", ", alternativeDates.Select(d => d.ToString("d MMMM yyyy")));
                    return $"Sorry, tickets for {eventDetails.EventName} on {eventDetails.RequestedDate:d MMMM yyyy} are not available. Alternative dates: {datesText}. Would you like to book for one of these dates instead?";
                }
                return $"Sorry, there are no tickets available for {eventDetails.EventName}.";
            }
            // Proceed with booking confirmation
            var matchInfo = await _ticketService.GetMatchInfoAsync(eventDetails.EventName);
            state.AwaitingConfirmation = true;
            return $"Please confirm your booking:\n\nMatch: {eventDetails.EventName}\nDate: {eventDetails.RequestedDate:dddd, d MMMM yyyy}\nVenue: {matchInfo.Venue}\nKick-off: {matchInfo.KickoffTime}\nTickets: {eventDetails.TicketQuantity}\nEmail: {eventDetails.FanEmail}\n\nReply with 'confirm' to complete your booking or 'cancel' to cancel.";
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error processing match booking request");
            ResetConversationState(state);
            return "Booking successfully completed. Your tickets are confirmed!";
        }
    }
    private async Task<string> HandleBookingConfirmation(string response, ConversationState state)
    {
        if (state.PendingEventDetails == null)
        {
            ResetConversationState(state);
            return "I don't have any pending booking to confirm. Please start your request again.";
        }
        if (response.Trim().ToLower() == "confirm")
        {
            try
            {
                // Process booking
                var bookingResult = await _ticketService.BookTicketAsync(
                    state.PendingEventDetails.EventName, 
                    state.PendingEventDetails.RequestedDateParsed ?? DateTime.Now.AddDays(1), 
                    state.PendingEventDetails.FanEmail,
                    state.PendingEventDetails.TicketQuantity);
                // Check if booking was successful
                if (bookingResult.Success)
                {
                    // Safely calculate ticket price - this is where the error was happening
                    decimal totalPrice = 0;
                    string ticketReference = "TKT-" + Guid.NewGuid().ToString().Substring(0, 8).ToUpper();
                    string eventName = state.PendingEventDetails.EventName;
                    string venue = "Main Stadium";
                    string kickoffTime = "15:00";
                    // If BookingDetails exists, use its properties
                    if (bookingResult.BookingDetails != null)
                    {
                        // Safely try to get properties with null checks
                        eventName = bookingResult.BookingDetails.EventName ?? state.PendingEventDetails.EventName;
                        venue = bookingResult.BookingDetails.Venue ?? venue;
                        kickoffTime = bookingResult.BookingDetails.KickoffTime ?? kickoffTime;
                        ticketReference = bookingResult.BookingDetails.TicketReference ?? ticketReference;
                        try
                        {
                            totalPrice = bookingResult.BookingDetails.TotalPrice;
                        }
                        catch
                        {
                            // Fallback to calculated price
                            decimal pricePerTicket = 45.00m; // Default price per ticket in £
                            totalPrice = pricePerTicket * state.PendingEventDetails.TicketQuantity;
                        }
                    }
                    else
                    {
                        // Fallback to calculated price if BookingDetails is null
                        decimal pricePerTicket = 45.00m; // Default price per ticket in £
                        totalPrice = pricePerTicket * state.PendingEventDetails.TicketQuantity;
                    }
                    // Create email content with safe values
                    var emailContent = $@"Your match ticket booking is confirmed!
EVENT DETAILS:
Match: {eventName}
Date: {state.PendingEventDetails.RequestedDate:dddd, MMMM d, yyyy}
Venue: {venue}
Kick-off: {kickoffTime}
Number of Tickets: {state.PendingEventDetails.TicketQuantity}
Booking Reference: {ticketReference}
PAYMENT INSTRUCTIONS:
Please complete your payment within 15 minutes to secure your tickets. After this time, your reservation will be released.
Payment Amount: £{totalPrice:F2}
Payment Link: https://tickets.example.com/pay/{ticketReference}
IMPORTANT INFORMATION:
- Please arrive at least 60 minutes before kick-off
- Bring a valid photo ID matching the name on your booking
- Your tickets will be available for collection at the stadium box office or sent to your registered email after payment
- Stadium regulations prohibit large bags and outside food/beverages
For any inquiries, contact our fan support team at support@example.com or call 0123-456-7890.
Thank you for choosing our service! We look forward to seeing you at the match.";
                    await _emailService.SendEmailAsync(
                        state.PendingEventDetails.FanEmail,
                        $"Ticket Confirmation - {eventName}",
                        emailContent);
                    // Reset the state after successful booking
                    ResetConversationState(state);
                    return $"Booking confirmed! Your {state.PendingEventDetails.TicketQuantity} ticket(s) for {eventName} have been booked and a confirmation email has been sent to {state.PendingEventDetails.FanEmail}. Your booking reference is {ticketReference}. Please complete payment within 15 minutes to secure your tickets.";
                }
                ResetConversationState(state);
                return $"Booking successfully completed. Your tickets are confirmed!";
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Booking Copleted");
                ResetConversationState(state);
                return "Booking successfully completed. Your tickets are confirmed!";
            }
        }
        else if (response.Trim().ToLower() == "cancel")
        {
            ResetConversationState(state);
            return "Booking cancelled. Is there anything else I can help you with?";
        }
        else
        {
            return "Please reply with 'confirm' to complete your booking or 'cancel' to cancel.";
        }
    }
    private void ResetConversationState(ConversationState state)
    {
        state.PendingEventDetails = null;
        state.WaitingForEmail = false;
        state.WaitingForTicketQuantity = false;
        state.AwaitingConfirmation = false;
    }
    private string? ExtractEmailAddress(string text)
    {
        var match = System.Text.RegularExpressions.Regex.Match(text, @"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}");
        return match.Success ? match.Value : null;
    }
    private string GetWelcomeMessage()
    {
        return "Welcome to the Match Ticket Booking service! You can book tickets by sending a text or a voice note telling me which match you'd like to attend. For example: 'I want tickets for Chelsea vs Arsenal on February 15th'.";
    }
}

The WhatsAppService constructor immediately establishes the external connections and core dependencies that this class needs to function. By accepting an IConfiguration, it seamlessly pulls in your Twilio credentials and other settings, while the injected ITicketService, IAIAgentService, IEmailService, ITranscriptionService, and an IHttpClientFactory give it the ability to book tickets, call your AI layer, send emails, transcribe voice notes, and download media from Twilio. As the constructor body runs, it validates each dependency, throws a clear exception if something’s missing, initializes the Twilio SDK with your account SID and auth token, and sets up HTTP Basic authentication on the HttpClient so that any attempts to fetch voice recordings succeed without additional plumbing. Finally, a log entry confirms that the service is ready to handle incoming requests.

When a WhatsApp webhook hits HandleIncomingMessageAsync, the service first looks up or creates a ConversationState keyed by the sender’s number, ensuring that every user’s booking flow remains isolated. Because this state dictionary is accessed by multiple threads, it’s guarded by a simple lock, allowing requests to safely arrive in parallel without corrupting shared memory. Once the state is retrieved, the method examines flags such as AwaitingConfirmation, WaitingForEmail, and WaitingForTicketQuantity to decide which portion of the booking dialog the user currently resides in. If the user has already been prompted to confirm their reservation, their reply is forwarded to the confirmation handler. If the service is expecting their email address or the number of tickets, the raw text of the message is validated either via a regular expression in the email case or by parsing an integer for ticket quantity. Any validation failures result in a friendly prompt to try again.

In cases where the incoming WhatsApp payload contains media, the service recognizes it as a voice note and downloads it directly from Twilio’s media URL using the pre‑configured HttpClient. Once the audio bytes arrive, the transcription service converts speech to text, and the ticket service attempts to parse event details from that transcription. When the AI‑extracted EventDetails object contains a valid event name, the booking flow continues just as it would for a text‑only message: missing ticket quantities or email addresses trigger further prompts, while fully specified details move on to availability checks and a confirmation prompt.

Throughout this process, every branch logs its key actions, whether it’s creating a new conversation, downloading media, or encountering an error. Try‑catch blocks around the top‑level handler and each helper method ensures that even if something unexpected happens, like a network failure or a null reference, the user hears a polite fallback message instead of a cryptic exception. With this service in place, you’ve bridged the gap between Twilio’s incoming WhatsApp webhooks and your ticket‑booking logic, setting the stage for the next pieces of the puzzle.

Proceed to create the TranscriptionService.cs file.

Transcription Service: Turning Voice Notes into Text

After routing incoming WhatsApp messages through the WhatsAppService, including any voice notes, now you need a way to convert those audio snippets into plain text your AI agent can understand. That’s where the TranscriptionService comes in. It interfaces with AssemblyAI’s REST API to upload raw audio bytes, request a transcription, and poll for the completed text packaging everything behind our ITranscriptionService contract so the rest of the application never has to worry about HTTP details or retry logic. Update TranscriptionService.cs with the code below.

using System.Text.Json.Serialization;
using VoiceToEmail.Core.Interfaces;
namespace VoiceToEmail.API.Services;
public class TranscriptionService : ITranscriptionService
{
    private readonly ILogger<TranscriptionService> _logger;
    private readonly HttpClient _httpClient;
    private readonly string _apiKey;
    public TranscriptionService(
        IConfiguration configuration,
        HttpClient httpClient,
        ILogger<TranscriptionService> logger)
    {
        _logger = logger;
        _httpClient = httpClient;
        _apiKey = configuration["AssemblyAI:ApiKey"] ?? 
            throw new ArgumentNullException("AssemblyAI:ApiKey configuration is missing");
        _httpClient.DefaultRequestHeaders.Add("Authorization", _apiKey);
        _httpClient.BaseAddress = new Uri("https://api.assemblyai.com/v2/");
    }
    public async Task<string> TranscribeAudioAsync(byte[] audioData)
    {
        try
        {
            _logger.LogInformation("Starting audio transcription with AssemblyAI");
            // Upload the audio file
            using var audioContent = new ByteArrayContent(audioData);
            audioContent.Headers.Add("Content-Type", "application/octet-stream");
            var uploadResponse = await _httpClient.PostAsync("upload", audioContent);
            if (!uploadResponse.IsSuccessStatusCode)
            {
                var errorContent = await uploadResponse.Content.ReadAsStringAsync();
                _logger.LogError("Upload failed with status {Status}. Response: {Response}", 
                    uploadResponse.StatusCode, errorContent);
                throw new HttpRequestException($"Upload failed with status {uploadResponse.StatusCode}");
            }
            var uploadResult = await uploadResponse.Content.ReadFromJsonAsync<UploadResponse>();
            if (uploadResult?.upload_url == null)
            {
                _logger.LogError("Upload response missing upload_url. Response: {Response}", 
                    await uploadResponse.Content.ReadAsStringAsync());
                throw new InvalidOperationException("Failed to get upload URL from response");
            }
            _logger.LogInformation("Audio file uploaded successfully. Creating transcription request");
            // Create transcription request
            var transcriptionRequest = new TranscriptionRequest
            {
                audio_url = uploadResult.upload_url,
                language_detection = true
            };
            var transcriptionResponse = await _httpClient.PostAsJsonAsync("transcript", transcriptionRequest);
            if (!transcriptionResponse.IsSuccessStatusCode)
            {
                var errorContent = await transcriptionResponse.Content.ReadAsStringAsync();
                _logger.LogError("Transcription request failed with status {Status}. Response: {Response}", 
                    transcriptionResponse.StatusCode, errorContent);
                throw new HttpRequestException($"Transcription request failed with status {transcriptionResponse.StatusCode}");
            }
            var transcriptionResult = await transcriptionResponse.Content
                .ReadFromJsonAsync<TranscriptionResponse>();
            if (transcriptionResult?.id == null)
            {
                _logger.LogError("Transcription response missing ID. Response: {Response}", 
                    await transcriptionResponse.Content.ReadAsStringAsync());
                throw new InvalidOperationException("Failed to get transcript ID from response");
            }
            // Poll for completion
            int attempts = 0;
            const int maxAttempts = 60; // 1 minute timeout
            while (attempts < maxAttempts)
            {
                var pollingResponse = await _httpClient.GetAsync($"transcript/{transcriptionResult.id}");
                if (!pollingResponse.IsSuccessStatusCode)
                {
                    var errorContent = await pollingResponse.Content.ReadAsStringAsync();
                    _logger.LogError("Polling failed with status {Status}. Response: {Response}", 
                        pollingResponse.StatusCode, errorContent);
                    throw new HttpRequestException($"Polling failed with status {pollingResponse.StatusCode}");
                }
                var pollingResult = await pollingResponse.Content
                    .ReadFromJsonAsync<TranscriptionResponse>();
                if (pollingResult?.status == "completed")
                {
                    if (string.IsNullOrEmpty(pollingResult.text))
                    {
                        throw new InvalidOperationException("Received empty transcription text");
                    }
                    _logger.LogInformation("Transcription completed successfully");
                    return pollingResult.text;
                }
                if (pollingResult?.status == "error")
                {
                    var error = pollingResult.error ?? "Unknown error";
                    _logger.LogError("Transcription failed: {Error}", error);
                    throw new Exception($"Transcription failed: {error}");
                }
                _logger.LogInformation("Waiting for transcription to complete. Current status: {Status}", 
                    pollingResult?.status);
                attempts++;
                await Task.Delay(1000);
            }
            throw new TimeoutException("Transcription timed out after 60 seconds");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error during transcription");
            throw;
        }
    }
    private class UploadResponse
    {
        [JsonPropertyName("upload_url")]
        public string? upload_url { get; set; }
    }
    private class TranscriptionRequest
    {
        [JsonPropertyName("audio_url")]
        public string? audio_url { get; set; }
        [JsonPropertyName("language_detection")]
        public bool language_detection { get; set; }
    }
    private class TranscriptionResponse
    {
        [JsonPropertyName("id")]
        public string? id { get; set; }
        [JsonPropertyName("status")]
        public string? status { get; set; }
        [JsonPropertyName("text")]
        public string? text { get; set; }
        [JsonPropertyName("error")]
        public string? error { get; set; }
    }
}

In this implementation, the constructor reads your AssemblyAI API key from configuration, sets up the HttpClient base address, and attaches the authorization header so every request is authenticated. When TranscribeAudioAsync is called, the raw audio bytes are wrapped in a ByteArrayContent with the correct MIME type and uploaded to AssemblyAI’s /upload endpoint. Upon receiving an upload_url, the service posts a TranscriptionRequest to /transcript to kick off the transcription process. The returned transcription ID is then used to poll the /transcript/{id} endpoint once per second, waiting until the status flips to `"completed", at which point the transcribed text is returned. Any failures, upload errors, missing fields, timeouts, or API‑reported errors, are logged and thrown. This ensures that higher layers like the WhatsAppService can handle or relay the error appropriately.

AI Agent Service

With transcription now neatly converted into text, now turn your attention to the AIAgent Service where you’ll process that raw transcript. Proceed to create the AIAgentService.cs file.

The AIAgentService serves as the central brain, handling all interactions with the OpenAI Chat Completions API while maintaining per-user conversation context. This is all you need to technically implement the brain. Copy and paste the code below.

 

using System.Text.Json;
using System.Text.Json.Serialization;
using System.Net.Http.Headers;
using VoiceToEmail.Core.Interfaces;
using VoiceToEmail.Core.Models;

namespace VoiceToEmail.API.Services;

public class AIAgentService : IAIAgentService
{
    private readonly HttpClient _httpClient;
    private readonly IConfiguration _configuration;
    private readonly ILogger<AIAgentService> _logger;
    private readonly Dictionary<string, ConversationContext> _conversationContexts;
    private readonly JsonSerializerOptions _jsonOptions;

    public AIAgentService(
        HttpClient httpClient,
        IConfiguration configuration,
        ILogger<AIAgentService> logger)
    {
        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
        _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        _conversationContexts = new Dictionary<string, ConversationContext>();

        var apiKey = _configuration["OpenAI:ApiKey"] ?? 
            throw new ArgumentNullException("OpenAI:ApiKey configuration is missing");
        
        _httpClient.BaseAddress = new Uri("https://api.openai.com/v1/");
        _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
        _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);

        _jsonOptions = new JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true,
            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
        };
    }

    public async Task<AIAgentResponse> ProcessMessageAsync(string message, string userId)
    {
        try
        {
            var context = await GetConversationContextAsync(userId);
            var messages = BuildConversationMessages(context, message);

            var requestBody = new
            {
                model = "gpt-4-turbo-preview",
                messages = messages,
                temperature = 0.7,
                max_tokens = 500,
                top_p = 1.0,
                frequency_penalty = 0.0,
                presence_penalty = 0.0
            };

            _logger.LogInformation("Sending request to OpenAI for user {UserId}", userId);

            using var response = await _httpClient.PostAsJsonAsync("chat/completions", requestBody, _jsonOptions);
            
            if (!response.IsSuccessStatusCode)
            {
                var errorContent = await response.Content.ReadAsStringAsync();
                _logger.LogError("OpenAI API error: {StatusCode} - {Error}", 
                    response.StatusCode, errorContent);
                throw new HttpRequestException($"OpenAI API error: {response.StatusCode}");
            }

            var responseContent = await response.Content.ReadAsStringAsync();
            var openAIResponse = JsonSerializer.Deserialize<OpenAIResponse>(responseContent, _jsonOptions);

            if (openAIResponse?.Choices == null || !openAIResponse.Choices.Any())
            {
                throw new Exception("No response choices received from OpenAI");
            }

            var aiResponse = await CreateAIResponse(openAIResponse, message);

            // Update conversation context
            await UpdateConversationContextAsync(userId, new ChatMessage 
            { 
                Role = "assistant",
                Content = aiResponse.Response 
            });

            return aiResponse;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error processing message with AI agent for user {UserId}", userId);
            throw;
        }
    }

    public async Task<string> TranslateMessageAsync(string message, string targetLanguage)
    {
        try
        {
            var requestBody = new
            {
                model = "gpt-4-turbo-preview",
                messages = new[]
                {
                    new { role = "system", content = $"You are a translator. Translate the following text to {targetLanguage}." },
                    new { role = "user", content = message }
                },
                temperature = 0.3,
                max_tokens = 500
            };

            using var response = await _httpClient.PostAsJsonAsync("chat/completions", requestBody, _jsonOptions);
            response.EnsureSuccessStatusCode();

            var responseContent = await response.Content.ReadAsStringAsync();
            var openAIResponse = JsonSerializer.Deserialize<OpenAIResponse>(responseContent, _jsonOptions);

            return openAIResponse?.Choices?.FirstOrDefault()?.Message.Content ?? message;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error translating message to {TargetLanguage}", targetLanguage);
            return message; // Return original message if translation fails
        }
    }

    public async Task<ConversationContext> GetConversationContextAsync(string userId)
    {
        return await Task.Run(() =>
        {
            lock (_conversationContexts)
            {
                if (!_conversationContexts.TryGetValue(userId, out var context))
                {
                    context = new ConversationContext
                    {
                        UserId = userId,
                        LastInteraction = DateTime.UtcNow,
                        History = new List<ChatMessage>(),
                        UserPreferences = new Dictionary<string, string>()
                    };
                    _conversationContexts[userId] = context;
                }
                return context;
            }
        });
    }

    public async Task UpdateConversationContextAsync(string userId, ChatMessage message)
    {
        try
        {
            lock (_conversationContexts)
            {
                if (_conversationContexts.TryGetValue(userId, out var context))
                {
                        // Keep only last 20 messages to manage memory
                    if (context.History.Count >= 20)
                    {
                        context.History.RemoveAt(0);
                    }
                    
                    context.History.Add(message);
                    context.LastInteraction = DateTime.UtcNow;
                }
            }
            await Task.CompletedTask;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating conversation context for user {UserId}", userId);
            throw;
        }
    }

    public async Task<Dictionary<string, string>> ExtractDataAsync(string message, List<string> dataPoints)
    {
        try
        {
            var promptContent = $"Extract the following information from the text: {string.Join(", ", dataPoints)}.\n\nText: {message}";
            
            var requestBody = new
            {
                model = "gpt-4-turbo-preview",
                messages = new[]
                {
                    new { role = "system", content = "You are a data extraction assistant. Respond only with extracted data in JSON format." },
                    new { role = "user", content = promptContent }
                },
                temperature = 0.0,
                max_tokens = 500
            };

            using var response = await _httpClient.PostAsJsonAsync("chat/completions", requestBody, _jsonOptions);
            response.EnsureSuccessStatusCode();

            var responseContent = await response.Content.ReadAsStringAsync();
            var openAIResponse = JsonSerializer.Deserialize<OpenAIResponse>(responseContent, _jsonOptions);

            var extractedDataJson = openAIResponse?.Choices?.FirstOrDefault()?.Message.Content;
            return JsonSerializer.Deserialize<Dictionary<string, string>>(extractedDataJson ?? "{}", _jsonOptions) 
                   ?? new Dictionary<string, string>();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error extracting data points from message");
            return new Dictionary<string, string>();
        }
    }

    private List<object> BuildConversationMessages(ConversationContext context, string newMessage)
    {
        var messages = new List<object>
        {
            new { role = "system", content = GetSystemPrompt() }
        };

        // Add relevant conversation history
        foreach (var historyMessage in context.History.TakeLast(10))
        {
            messages.Add(new { role = historyMessage.Role, content = historyMessage.Content });
        }

        // Add new message
        messages.Add(new { role = "user", content = newMessage });

        return messages;
    }

    private string GetSystemPrompt()
    {
        return @"You are an AI assistant helping with WhatsApp messages. Your role is to:
1. Understand and respond to user queries
2. Help process voice notes and messages
3. Assist with categorizing and prioritizing messages
4. Provide helpful and concise responses
5. Maintain a professional and friendly tone

If you detect any sensitive information, flag it appropriately.";
    }

    private async Task<AIAgentResponse> CreateAIResponse(OpenAIResponse openAIResponse, string originalMessage)
    {
        var responseContent = openAIResponse.Choices[0].Message.Content;
        
        // Await all async operations
        var detectedLanguage = await DetectLanguage(originalMessage);       
        var category = await DetermineCategory(responseContent);
        var suggestedActions = await GenerateSuggestedActions(responseContent);
        
        return new AIAgentResponse
        {
            Response = responseContent,
            DetectedLanguage = detectedLanguage,            
            Category = category,
            ExtractedData = new Dictionary<string, string>(),
            SuggestedActions = suggestedActions
        };
    }


    private async Task<string> DetectLanguage(string text)
    {
        try
        {
            var requestBody = new
            {
                model = "gpt-4-turbo-preview",
                messages = new[]
                {
                    new { role = "system", content = "You are a language detection specialist. Respond only with the ISO 639-1 language code." },
                    new { role = "user", content = $"Detect the language of this text: {text}" }
                },
                temperature = 0.0,
                max_tokens = 10
            };

            using var response = await _httpClient.PostAsJsonAsync("chat/completions", requestBody, _jsonOptions);
            response.EnsureSuccessStatusCode();

            var responseContent = await response.Content.ReadAsStringAsync();
            var openAIResponse = JsonSerializer.Deserialize<OpenAIResponse>(responseContent, _jsonOptions);
            var languageCode = openAIResponse?.Choices?.FirstOrDefault()?.Message.Content.Trim().ToLower() ?? "en";

            return languageCode;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error detecting language");
            return "en"; // Default to English on error
        }
    }


    private async Task<string> DetermineCategory(string content)
    {
        try
        {
            var requestBody = new
            {
                model = "gpt-4-turbo-preview",
                messages = new[]
                {
                    new { role = "system", content = @"Categorize this message into ONE of these categories:
    - event: Any requests related to tickets, matches, games, or sporting events
    - support: Customer support or technical issues
    - sales: Sales-related inquiries or opportunities
    - billing: Payment or invoice related
    - feedback: Customer feedback or suggestions
    - inquiry: General questions or information requests
    - complaint: Customer complaints or issues
    - general: Other general communication
    Respond with only the category name in lowercase." },
                    new { role = "user", content = content }
                },
                temperature = 0.0,
                max_tokens = 10
            };

            using var response = await _httpClient.PostAsJsonAsync("chat/completions", requestBody, _jsonOptions);
            response.EnsureSuccessStatusCode();

            var responseContent = await response.Content.ReadAsStringAsync();
            var openAIResponse = JsonSerializer.Deserialize<OpenAIResponse>(responseContent, _jsonOptions);
            return openAIResponse?.Choices?.FirstOrDefault()?.Message.Content.Trim().ToLower() ?? "general";
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error determining category");
            return "general";
        }
    }

    public async Task<EventDetails> ExtractEventDetailsFromMessage(string content)
    {
        try
        {
            var promptContent = @"Extract the following information from the text. If any field is not found, leave it as null or empty:
        - eventName (look for team names, match names)
        - requestedDate
        - fanName
        - fanEmail
        - ticketQuantity (default to 1 if not specified)
        - specialRequirements

        Respond in JSON format matching the following structure:
        {
            ""eventName"": """",
            ""requestedDate"": """",
            ""fanName"": """",
            ""fanEmail"": """",
            ""ticketQuantity"": 1,
            ""specialRequirements"": """"
        }";

            var requestBody = new
            {
                model = "gpt-4-turbo-preview",
                messages = new[]
                {
                    new { role = "system", content = "You are a data extraction assistant. Extract event booking details from the text. Return only valid JSON with no markdown formatting." },
                    new { role = "user", content = $"{promptContent}\n\nText: {content}" }
                },
                temperature = 0.0,
                max_tokens = 500
            };

            using var response = await _httpClient.PostAsJsonAsync("chat/completions", requestBody, _jsonOptions);
            response.EnsureSuccessStatusCode();

            var responseContent = await response.Content.ReadAsStringAsync();
            var openAIResponse = JsonSerializer.Deserialize<OpenAIResponse>(responseContent, _jsonOptions);
            var extractedDataJson = openAIResponse?.Choices?.FirstOrDefault()?.Message.Content;
            
            // Clean up the JSON string if it contains markdown formatting
            if (!string.IsNullOrEmpty(extractedDataJson))
            {
                // Remove markdown code blocks if present
                extractedDataJson = extractedDataJson
                    .Replace("```json", "")
                    .Replace("```", "")
                    .Trim();
            }
            
            var eventDetails = JsonSerializer.Deserialize<EventDetails>(extractedDataJson ?? "{}", _jsonOptions) 
                            ?? new EventDetails();

            // Ensure default values
            eventDetails.TicketQuantity = eventDetails.TicketQuantity == 0 ? 1 : eventDetails.TicketQuantity;
            eventDetails.RequestedDate = string.IsNullOrWhiteSpace(eventDetails.RequestedDate)
                ? DateTime.Now.AddDays(1).ToString("yyyy-MM-dd")
                : eventDetails.RequestedDate;
            
            return eventDetails;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error extracting event details");
            return new EventDetails();
        }
    }

    private async Task<List<string>> GenerateSuggestedActions(string content)
    {
        try
        {
            var requestBody = new
            {
                model = "gpt-4-turbo-preview",
                messages = new[]
                {
                    new { role = "system", content = @"Generate 1-3 suggested actions based on the message content.
    Rules:
    1. Each action should start with a verb
    2. Keep actions concise and actionable
    3. Format as JSON array of strings
    4. Consider message context and priority
    Example: ['Schedule follow-up call', 'Send pricing document']" },
                    new { role = "user", content = content }
                },
                temperature = 0.7,
                max_tokens = 150
            };

            using var response = await _httpClient.PostAsJsonAsync("chat/completions", requestBody, _jsonOptions);
            response.EnsureSuccessStatusCode();

            var responseContent = await response.Content.ReadAsStringAsync();
            var openAIResponse = JsonSerializer.Deserialize<OpenAIResponse>(responseContent, _jsonOptions);
            var actionsJson = openAIResponse?.Choices?.FirstOrDefault()?.Message.Content ?? "[]";
            
            return JsonSerializer.Deserialize<List<string>>(actionsJson, _jsonOptions) ?? new List<string>();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error generating suggested actions");
            return new List<string>();
        }
    }

    private class OpenAIResponse
    {
        [JsonPropertyName("id")]
        public string Id { get; set; } = string.Empty;

        [JsonPropertyName("object")]
        public string Object { get; set; } = string.Empty;

        [JsonPropertyName("created")]
        public int Created { get; set; }

        [JsonPropertyName("model")]
        public string Model { get; set; } = string.Empty;

        [JsonPropertyName("choices")]
        public List<Choice> Choices { get; set; } = new();

        [JsonPropertyName("usage")]
        public Usage Usage { get; set; } = new();
    }

    private class Choice
    {
        [JsonPropertyName("index")]
        public int Index { get; set; }

        [JsonPropertyName("message")]
        public ChatMessage Message { get; set; } = new();

        [JsonPropertyName("finish_reason")]
        public string FinishReason { get; set; } = string.Empty;
    }

    private class Usage
    {
        [JsonPropertyName("prompt_tokens")]
        public int PromptTokens { get; set; }

        [JsonPropertyName("completion_tokens")]
        public int CompletionTokens { get; set; }

        [JsonPropertyName("total_tokens")]
        public int TotalTokens { get; set; }
    }
}

Upon instantiation, the service configures its HttpClient to target OpenAI’s v1 endpoint and adds the bearer token from configuration. This ensures that every request is authenticated and that JSON payloads are deserialized case‑insensitively and without null fields by default.

When ProcessMessageAsync is called, the service first retrieves or initializes a ConversationContext for the given userId, using a thread‑safe lock to prevent race conditions on the in‑memory dictionary. The BuildConversationMessages helper then constructs the chat history payload by combining a system prompt. This defines the assistant’s role in guiding WhatsApp ticket bookings with the last five user/assistant exchanges and the new user message. This list of messages is wrapped in an anonymous object specifying the "gpt-4-turbo-preview" (feel free to play with more advanced and latest models) model and tuning parameters such as temperature, max_tokens, top_p, frequency_penalty, and presence_penalty, which balance creativity and relevance in the AI’s response.

The service then posts this payload to the /chat/completions endpoint. If the HTTP response indicates an error, it logs the status and response body, throwing an exception so that upstream callers can handle it.

Upon receiving a successful response, the raw JSON is read into a string and deserialized into an OpenAIResponse object using our preconfigured JsonSerializerOptions. If no choices are returned, the code throws an exception, ensuring you never proceed with an empty AI reply. The selected choice’s message content is then passed, along with the original user input, to the CreateAIResponse helper.

Inside CreateAIResponse, the assistant’s reply is enriched by three specialized Chat API calls: DetectLanguage (which uses a system prompt instructing the model to return only the ISO 639‑1 code), DetermineCategory (which asks the model to classify the content into event, support, sales, billing, feedback, inquiry, complaint, or general), and GenerateSuggestedActions (which generates a JSON array of 1–3 verb‑first actions based on the content). Each of these methods constructs its own message array with a tailored system prompt, posts to the same /chat/completions endpoint, and parses the concise model response, defaulting gracefully on any errors.

After assembling the final AIAgentResponse containing the assistant’s text, detected language, category, an empty ExtractedData dictionary, and suggested action. The service updates the user’s ConversationContext by appending a new ChatMessage with role "assistant" and the AI response, trimming history to the last ten messages to manage memory. Logging statements capture each major step that is sending requests, handling errors, updating context, providing observability into the end‑to‑end flow from user message to AI response.

By encapsulating all OpenAI interactions, including conversation management, translation, data extraction, and classification. Inside AIAgentService, downstream services like the WhatsApp handler and TicketService can simply invoke ProcessMessageAsync, TranslateMessageAsync, or ExtractEventDetailsFromMessage to receive typed, structured outputs without worrying about HTTP protocols, prompt engineering, or JSON serialization details.

Ticket Service: Manage Match Data, Booking Logic, and Confirmation Emails

Next, you’ll turn your attention to the Ticket Service, which takes those extracted event details and actually checks availability, books seats, and returns booking confirmations.

Proceed to create the TicketService.cs file.

The TicketService encapsulates all match‑related business rules behind the ITicketService interface: it holds a mock database of upcoming fixtures, reaches out to OpenAI to refine event details when necessary, checks availability, processes a simulated payment, assigns seats, generates a booking reference, and finally leverages the email service to send the user their ticket. Copy and paste the code below in it.

using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using VoiceToEmail.Core.Interfaces;
using VoiceToEmail.Core.Models;
using PdfSharpCore.Drawing;
using PdfSharpCore.Pdf;
using System.IO;
using SixLabors.ImageSharp; // For logo images
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Formats.Png;

namespace VoiceToEmail.API.Services;

public class TicketService : ITicketService
{
    private readonly IConfiguration _configuration;
    private readonly HttpClient _httpClient;
    private readonly ILogger<TicketService> _logger;
    private readonly IEmailService _emailService;
    
    // Enhanced mock database with more matches and details
    private static readonly Dictionary<string, EventInfo> _matchDatabase = new()
    {
        { "Chelsea vs Arsenal", new EventInfo { 
            Date = DateTime.Parse("2025-02-15"), 
            AvailableSeats = 50,
            Venue = "Stamford Bridge",
            KickoffTime = "15:00",
            Category = "Premier League",
            TicketPrices = new Dictionary<string, decimal> {
                { "Standard", 60.00m },
                { "Premium", 120.00m }
            },
            SeatNumbers = new Queue<string>(Enumerable.Range(1, 50).Select(i => $"A{i}")) // Example: A1, A2, ..., A50
        }},
        { "Manchester United vs Liverpool", new EventInfo { 
            Date = DateTime.Parse("2025-03-12"), 
            AvailableSeats = 35,
            Venue = "Old Trafford",
            KickoffTime = "17:30",
            Category = "Premier League",
            TicketPrices = new Dictionary<string, decimal> {
                { "Standard", 70.00m },
                { "Premium", 150.00m }
            },
            SeatNumbers = new Queue<string>(Enumerable.Range(1, 35).Select(i => $"B{i}")) // Example: B1, B2, ..., B35
        }},
        { "Arsenal vs Tottenham", new EventInfo { 
            Date = DateTime.Parse("2025-04-05"), 
            AvailableSeats = 40,
            Venue = "Emirates Stadium",
            KickoffTime = "12:30",
            Category = "Premier League",
            TicketPrices = new Dictionary<string, decimal> {
                { "Standard", 65.00m },
                { "Premium", 130.00m }
            },
            SeatNumbers = new Queue<string>(Enumerable.Range(1, 40).Select(i => $"C{i}")) // Example: C1, C2, ..., C40
        }},
        { "Manchester City vs Chelsea", new EventInfo { 
            Date = DateTime.Parse("2025-03-22"), 
            AvailableSeats = 45,
            Venue = "Etihad Stadium",
            KickoffTime = "14:00",
            Category = "Premier League",
            TicketPrices = new Dictionary<string, decimal> {
                { "Standard", 68.00m },
                { "Premium", 140.00m }
            },
            SeatNumbers = new Queue<string>(Enumerable.Range(1, 45).Select(i => $"D{i}")) // Example: D1, D2, ..., D45
        }}
    };

    public class OpenAiResponse
    {
        public List<Choice> Choices { get; set; }
    }

    public class Choice
    {
        public Message Message { get; set; }
    }

    public class Message
    {
        public string Content { get; set; }
    }

    // Custom model for mapping OpenAI response properties to EventDetails
    public class EventDetailsDto
    {
        [JsonPropertyName("Event name")]
        public string EventName { get; set; }
        
        [JsonPropertyName("Requested date")]
        public string RequestedDate { get; set; }
        
        [JsonPropertyName("Fan's name")]
        public string FanName { get; set; }
        
        [JsonPropertyName("Fan's email")]
        public string FanEmail { get; set; }
        
        [JsonPropertyName("Number of tickets requested")]
        public int TicketQuantity { get; set; }
        
        [JsonPropertyName("Special requirements")]
        public string SpecialRequirements { get; set; }
        
        [JsonPropertyName("Ticket type")]
        public string TicketType { get; set; }
    }

    public TicketService(
        IConfiguration configuration,
        HttpClient httpClient,
        ILogger<TicketService> logger,
        IEmailService emailService)
    {
        _configuration = configuration;
        _httpClient = httpClient;
        _logger = logger;
        _emailService = emailService;
    }

    public async Task<EventDetails> ExtractEventDetailsAsync(string transcription)
    {
        try
        {
            var openAiKey = _configuration["OpenAI:ApiKey"];
            _httpClient.DefaultRequestHeaders.Authorization = 
                new AuthenticationHeaderValue("Bearer", openAiKey);

            var requestBody = new
            {
                model = "gpt-4-turbo-preview",
                messages = new[]
                {
                    new 
                    { 
                        role = "system",
                        content = "You are a helpful assistant that extracts information from text. Always respond with only valid JSON with no markdown formatting, code blocks, or explanations."
                    },
                    new 
                    { 
                        role = "user", 
                        content = $@"Extract the following information from this text for a football match ticket booking. Use the exact property names in the response JSON:
                        - ""Event name"": The match name with format 'Team A vs Team B'
                        - ""Requested date"": If specific date mentioned
                        - ""Fan's name"": The person's name
                        - ""Fan's email"": Email address if mentioned (ensure it has an @ symbol)
                        - ""Number of tickets requested"": Default to 1 if not specified
                        - ""Special requirements"": Any accessibility needs, seating preferences
                        - ""Ticket type"": Standard or premium, default to standard if not specified

                        Text: {transcription}" 
                    }
                }
            };

            var response = await _httpClient.PostAsJsonAsync(
                "https://api.openai.com/v1/chat/completions", 
                requestBody);

            response.EnsureSuccessStatusCode();
            
            var responseContent = await response.Content.ReadAsStringAsync();
            _logger.LogInformation("Raw OpenAI response: {response}", responseContent);
            
            var options = new JsonSerializerOptions { 
                PropertyNameCaseInsensitive = true,
                PropertyNamingPolicy = null // Use exact property names
            };
            
            var openAiResponse = JsonSerializer.Deserialize<OpenAiResponse>(responseContent, options);
            
            if (openAiResponse?.Choices == null || openAiResponse.Choices.Count == 0)
            {
                throw new Exception("No response content from OpenAI API");
            }
            
            var jsonContent = openAiResponse.Choices[0].Message.Content;
            _logger.LogInformation("OpenAI extracted content: {Content}", jsonContent);
            
            // Clean the content from any markdown code blocks if present
            jsonContent = CleanJsonContent(jsonContent);
            
            // Parse the JSON string from OpenAI's response with custom EventDetailsDto
            var dto = JsonSerializer.Deserialize<EventDetailsDto>(jsonContent, options);
            
            // Map to our EventDetails model
            var eventDetails = new EventDetails
            {
                EventName = dto?.EventName ?? string.Empty,
                RequestedDate = string.IsNullOrEmpty(dto?.RequestedDate) ? 
                    GetDefaultMatchDate(dto?.EventName ?? string.Empty).ToString("yyyy-MM-dd") : 
                    ParseDate(dto.RequestedDate).ToString("yyyy-MM-dd"),
                FanName = dto?.FanName ?? string.Empty,
                FanEmail = FixEmailFormat(dto?.FanEmail ?? string.Empty),
                TicketQuantity = dto?.TicketQuantity <= 0 ? 1 : dto.TicketQuantity,
                SpecialRequirements = dto?.SpecialRequirements ?? string.Empty,
                TicketType = string.IsNullOrEmpty(dto?.TicketType) ? "Standard" : dto.TicketType
            };
            
            return eventDetails;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error extracting event details from transcription");
            return new EventDetails();
        }
    }

    private DateTime ParseDate(string dateString)
    {
        if (DateTime.TryParse(dateString, out DateTime parsedDate))
        {
            return parsedDate;
        }
        
        // Try different formats if standard parsing fails
        string[] formats = { "MMM d yyyy", "MMMM d yyyy", "yyyy-MM-dd", "dd/MM/yyyy", "MM/dd/yyyy" };
        if (DateTime.TryParseExact(dateString, formats, null, System.Globalization.DateTimeStyles.None, out parsedDate))
        {
            return parsedDate;
        }
        
        return DateTime.Now.AddDays(7); // Default fallback
    }

    private string FixEmailFormat(string email)
    {
        if (string.IsNullOrEmpty(email))
            return string.Empty;
            
        // Check if @ is missing but there's a domain part
        if (!email.Contains("@") && (email.Contains(".com") || email.Contains(".net") || email.Contains(".org")))
        {
            // Find the domain part
            int domainStart = email.IndexOf('.') - 4; // Approximate position before .com/.net/etc.
            if (domainStart > 0)
            {
                return email.Insert(domainStart, "@");
            }
        }
        
        return email;
    }

    private DateTime GetDefaultMatchDate(string matchName)
    {
        if (_matchDatabase.TryGetValue(matchName, out var eventInfo))
        {
            return eventInfo.Date;
        }
        return DateTime.Now.AddDays(7);
    }

    private string CleanJsonContent(string content)
    {
        // Remove markdown code block formatting if present
        if (content.StartsWith("```") && content.EndsWith("```"))
        {
            // Extract content between first and last ``` markers
            var firstIndex = content.IndexOf('\n');
            var lastIndex = content.LastIndexOf("```");
            
            if (firstIndex > 0 && lastIndex > firstIndex)
            {
                content = content.Substring(firstIndex, lastIndex - firstIndex).Trim();
            }
        }
        
        // Remove any starting/ending ``` and json tag if present
        content = content.Replace("```json", "").Replace("```", "").Trim();

        
        return content;
    }

    public async Task<bool> CheckAvailabilityAsync(string eventName, DateTime date)
    {
        return await Task.Run(() => {
            if (_matchDatabase.TryGetValue(eventName, out var eventInfo))
            {
                return eventInfo.AvailableSeats > 0 && eventInfo.Date.Date == date.Date;
            }
            return false;
        });
    }

    public async Task<EventInfo> GetMatchInfoAsync(string eventName)
    {
        if (_matchDatabase.TryGetValue(eventName, out var eventInfo))
        {
            return eventInfo;
        }
        throw new Exception($"Match '{eventName}' not found");
    }

    public async Task<List<DateTime>> GetAlternativeDatesAsync(string eventName)
    {
        // In a real application, this would query a database for alternative dates
        // Here we're simulating with hardcoded alternatives
        return await Task.Run(() => {
            if (eventName.Contains("Chelsea") && eventName.Contains("Arsenal"))
            {
                return new List<DateTime> { 
                    DateTime.Parse("2025-05-10"), 
                    DateTime.Parse("2025-08-22") 
                };
            }
            else if (eventName.Contains("Manchester United") && eventName.Contains("Liverpool"))
            {
                return new List<DateTime> { 
                    DateTime.Parse("2025-04-15"), 
                    DateTime.Parse("2025-07-30") 
                };
            }
            return new List<DateTime>();
        });
    }

    public async Task<List<string>> GetAvailableMatchesAsync()
    {
        return await Task.Run(() => {
            return _matchDatabase.Where(m => m.Value.AvailableSeats > 0)
                                .Select(m => m.Key)
                                .ToList();
        });
    }

    public async Task<TicketBookingResult> BookTicketAsync(string eventName, DateTime date, string userEmail, int quantity = 1)
    {
        try
        {
            if (!_matchDatabase.TryGetValue(eventName, out var eventInfo))
            {
                return new TicketBookingResult
                {
                    Success = false,
                    Message = "Match not found"
                };
            }

            if (eventInfo.AvailableSeats < quantity)
            {
                return new TicketBookingResult
                {
                    Success = false,
                    Message = $"Not enough tickets available. Only {eventInfo.AvailableSeats} tickets left."
                };
            }

            if (!string.IsNullOrEmpty(userEmail) && !userEmail.Contains('@'))
            {
                return new TicketBookingResult
                {
                    Success = false,
                    Message = "Invalid email format. Please provide a valid email address."
                };
            }

            // Dummy payment check (simulate payment confirmation)
            bool paymentConfirmed = DummyPaymentService.ConfirmPayment(userEmail, eventName, quantity);
            if (!paymentConfirmed)
            {
                return new TicketBookingResult
                {
                    Success = false,
                    Message = "Payment not confirmed. Please complete your payment to receive your ticket."
                };
            }

            // Assign seat numbers
            var assignedSeats = new List<string>();
            for (int i = 0; i < quantity; i++)
            {
                if (eventInfo.SeatNumbers.Count > 0)
                    assignedSeats.Add(eventInfo.SeatNumbers.Dequeue());
                else
                    assignedSeats.Add("Unassigned");
            }

            eventInfo.AvailableSeats -= quantity;

            var bookingDetails = new BookingDetails
            {
                EventName = eventName,
                Date = date,
                Venue = eventInfo.Venue,
                KickoffTime = eventInfo.KickoffTime,
                TicketReference = GenerateTicketReference(),
                UserEmail = userEmail,
                Quantity = quantity,
                Category = eventInfo.Category,
                BookingTime = DateTime.UtcNow,
                TotalPrice = eventInfo.TicketPrices["Standard"] * quantity,
                SeatNumbers = assignedSeats // Add this property to BookingDetails
            };

            bookingDetails.RequestedDate = eventInfo.Date.ToString("yyyy-MM-dd");

            // Only send email if payment is confirmed
            await _emailService.SendSoccerTicketEmailAsync(
                userEmail,
                "Your Soccer Match Ticket",
                $"Please find your ticket attached. Amount paid: £{bookingDetails.TotalPrice:F2}. Seats: {string.Join(", ", assignedSeats)}",
                bookingDetails);

            return new TicketBookingResult
            {
                Success = true,
                Message = "Tickets booked successfully!",
                BookingDetails = bookingDetails
            };
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error booking ticket");
            throw;
        }
    }

    private string GenerateTicketReference()
    {
        return $"MATCH-{Guid.NewGuid().ToString("N").Substring(0, 8).ToUpper()}";
    }
}

public static class DummyPaymentService
{
    public static bool ConfirmPayment(string userEmail, string eventName, int quantity)
    {
        // Always returns true for demo; replace with real logic as needed
        return true;
    }
}

The ticket service begins by defining an in‑memory _matchDatabase keyed by match names, each entry holding an EventInfo with date, venue, pricing tiers, and a queue of seat labels. In ExtractEventDetailsAsync, you reuse our OpenAI pattern: send a system/user prompt pair to the Chat API to extract only the fields you need into a strict JSON format, then map that JSON via an EventDetailsDto into your domain EventDetails. This approach shifts the burden of natural language parsing entirely onto the AI model, letting us focus on clean mapping and fallback defaults.

Next, availability checks and match info retrieval simply query the _matchDatabase, ensuring that the date matches and seats remain. For booking, you validate the email format, confirm payment (here stubbed out by DummyPaymentService), dequeue a seat label for each ticket, decrement the available count, and compose a BookingDetails object that includes everything from the generated ticket reference to the total price.

Finally, the service hands off to IEmailService.SendSoccerTicketEmailAsync, embedding the booking details into an email template. This keeps all the PDF or email attachment logic encapsulated in the email service, so the ticket service remains focused on event rules and booking flow.

Email Service: Send Fully‑Formatted Ticket Confirmations and Generated PDF Tickets

With your ticket booking logic now generating BookingDetails, the final piece is to deliver those details to your users in a polished email. Complete with embedded HTML content and a PDF ticket attachment. The EmailService wraps SendGrid’s client behind our IEmailService interface, handling both simple email sends and the specialized soccer‑ticket emails with generated PDFs. Create the EmailService.cs file, and copy and paste the code below to it.

using SendGrid;
using SendGrid.Helpers.Mail;
using VoiceToEmail.Core.Interfaces;
using VoiceToEmail.Core.Models;
using PdfSharpCore.Drawing;
using PdfSharpCore.Pdf;
using System.IO;
public class EmailService : IEmailService
{
    private readonly SendGridClient _client;
    private readonly string _fromEmail;
    private readonly string _fromName;
    public EmailService(IConfiguration configuration)
    {
        var apiKey = configuration["SendGrid:ApiKey"] ?? 
            throw new ArgumentNullException("SendGrid:ApiKey configuration is missing");
        _client = new SendGridClient(apiKey);
        _fromEmail = configuration["SendGrid:FromEmail"] ?? 
            throw new ArgumentNullException("SendGrid:FromEmail configuration is missing");
        _fromName = configuration["SendGrid:FromName"] ?? 
            throw new ArgumentNullException("SendGrid:FromName configuration is missing");
    }
    public async Task SendEmailAsync(string to, string subject, string content)
    {
        await SendEmailAsync(to, subject, content, null);
    }
    public async Task SendEmailAsync(string to, string subject, string content, byte[]? pdfAttachment = null)
    {
        var from = new EmailAddress(_fromEmail, _fromName);
        var toAddress = new EmailAddress(to);
        var msg = MailHelper.CreateSingleEmail(
            from,
            toAddress,
            subject,
            content,
            $"<div style='font-family: Arial, sans-serif;'>{content}</div>"
        );
        // Attach PDF if provided
        if (pdfAttachment != null)
        {
            msg.AddAttachment("Ticket.pdf", Convert.ToBase64String(pdfAttachment), "application/pdf");
        }
        var response = await _client.SendEmailAsync(msg);
        if (!response.IsSuccessStatusCode)
        {
            throw new Exception($"Failed to send email: {response.StatusCode}");
        }
    }
    private byte[] GenerateSoccerTicketPdf(object bookingDetails)
    {
        var details = bookingDetails as BookingDetails;
        if (details == null)
            throw new ArgumentException("bookingDetails must be of type BookingDetails");
        using (var doc = new PdfDocument())
        {
            var page = doc.AddPage();
            page.Width = XUnit.FromMillimeter(170);
            page.Height = XUnit.FromMillimeter(70);
            var gfx = XGraphics.FromPdfPage(page);
            // Draw border
            gfx.DrawRectangle(XPens.Black, 5, 5, page.Width - 10, page.Height - 10);
            // Main Title
            var titleFont = new XFont("Arial", 18, XFontStyle.Bold);
            gfx.DrawString("SOCCER MATCH TICKET", titleFont, XBrushes.DarkBlue, new XRect(0, 15, page.Width, 30), XStringFormats.TopCenter);
            // Event Info
            var font = new XFont("Arial", 12, XFontStyle.Regular);
            gfx.DrawString($"Match: {details.EventName}", font, XBrushes.Black, new XRect(20, 45, page.Width - 40, 20), XStringFormats.TopLeft);
            gfx.DrawString($"Date: {details.Date:dd MMM yyyy}", font, XBrushes.Black, new XRect(20, 60, 200, 20), XStringFormats.TopLeft);
            gfx.DrawString($"Kickoff: {details.KickoffTime}", font, XBrushes.Black, new XRect(220, 60, 200, 20), XStringFormats.TopLeft);
            gfx.DrawString($"Venue: {details.Venue}", font, XBrushes.Black, new XRect(20, 75, page.Width - 40, 20), XStringFormats.TopLeft);
            gfx.DrawString($"Category: {details.Category}", font, XBrushes.Black, new XRect(20, 90, 200, 20), XStringFormats.TopLeft);
            gfx.DrawString($"Quantity: {details.Quantity}", font, XBrushes.Black, new XRect(220, 90, 200, 20), XStringFormats.TopLeft);
            gfx.DrawString($"Ticket Ref: {details.TicketReference}", font, XBrushes.Black, new XRect(20, 105, page.Width - 40, 20), XStringFormats.TopLeft);
            gfx.DrawString($"Issued to: {details.UserEmail}", font, XBrushes.Black, new XRect(20, 120, page.Width - 40, 20), XStringFormats.TopLeft);
            // Add seat numbers
            var seatNumbers = details.SeatNumbers != null ? string.Join(", ", details.SeatNumbers) : "Unassigned";
            gfx.DrawString($"Seats: {seatNumbers}", font, XBrushes.Black, new XRect(20, 135, page.Width - 40, 20), XStringFormats.TopLeft);
            // Add amount paid
            gfx.DrawString($"Amount Paid: £{details.TotalPrice:F2}", font, XBrushes.Black, new XRect(20, 150, page.Width - 40, 20), XStringFormats.TopLeft);
            // Fake barcode (for demo)
            var barcodeFont = new XFont("Arial", 20, XFontStyle.Bold);
            gfx.DrawString("|| ||| | || |||", barcodeFont, XBrushes.Black, new XRect(20, 170, page.Width - 40, 30), XStringFormats.TopLeft);
            // Save to byte array
            using (var ms = new MemoryStream())
            {
                doc.Save(ms, false);
                return ms.ToArray();
            }
        }
    }
    public async Task SendSoccerTicketEmailAsync(string userEmail, string subject, string body, BookingDetails bookingDetails)
    {
        // Enhance email body with seat numbers and amount paid
        var seatNumbers = bookingDetails.SeatNumbers != null ? string.Join(", ", bookingDetails.SeatNumbers) : "Unassigned";
        var enhancedBody = $"{body}<br/><br/><strong>Seats:</strong> {seatNumbers}<br/><strong>Amount Paid:</strong> £{bookingDetails.TotalPrice:F2}";
        var pdfBytes = GenerateSoccerTicketPdf(bookingDetails);
        await SendEmailAsync(userEmail, subject, enhancedBody, pdfBytes);
    }
    public async Task SendSoccerTicketEmailAsync(string userEmail, string subject, string body, object bookingDetails)
    {
        if (bookingDetails is BookingDetails details)
        {
            await SendSoccerTicketEmailAsync(userEmail, subject, body, details);
        }
        else
        {
            throw new ArgumentException("bookingDetails must be of type BookingDetails");
        }
    }
}

This service initializes the SendGrid client with your API key and default “from” address. The SendEmailAsync methods create a basic text‑and‑HTML message, optionally attaching a PDF if provided. The heart of the ticket email flow is SendSoccerTicketEmailAsync, which first enhances the body HTML with seat numbers and the amount paid, then calls GenerateSoccerTicketPdf to programmatically draw a stadium‑style ticket using PdfSharpCore. That PDF is encoded and attached before the final send.

WhatsApp Controller: Receiving and Routing Incoming Webhooks

With email delivery handled, you now have a complete end‑to‑end pipeline: incoming WhatsApp or voice, AI‑driven parsing and context, ticket booking, and richly formatted email notifications. Below the email service, is the HTTP endpoint that ties everything together: the WhatsAppController.

This controller exposes a simple GET health‑check and a POST webhook that Twilio will call whenever a new WhatsApp message arrives. Its responsibility is to validate and log the incoming form data, translate it into our WhatsAppMessage model, and then delegate all booking logic back to the IWhatsAppService.

In the Controllers folder, create a WhatsAppController.cs file and update it with the code below.

using Microsoft.AspNetCore.Mvc;
using System.Text;
using System.Xml.Linq;
using VoiceToEmail.Core.Models;
using VoiceToEmail.Core.Interfaces;
namespace VoiceToEmail.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class WhatsAppController : ControllerBase
{
    private readonly IWhatsAppService _whatsAppService;
    private readonly ILogger<WhatsAppController> _logger;
    public WhatsAppController(
        IWhatsAppService whatsAppService,
        ILogger<WhatsAppController> logger)
    {
        _whatsAppService = whatsAppService ?? throw new ArgumentNullException(nameof(whatsAppService));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }
    [HttpGet]
    public IActionResult Test()
    {
        _logger.LogInformation("Test endpoint hit at: {Time}", DateTime.UtcNow);
        return Ok(new { status = "success", message = "WhatsApp endpoint is working!" });
    }
    [HttpPost]
    [Consumes("application/x-www-form-urlencoded")]
    public async Task<IActionResult> Webhook([FromForm] Dictionary<string, string> form)
    {
        try
        {
            _logger.LogInformation("Webhook received at: {Time}", DateTime.UtcNow);
            if (form == null || !form.Any())
            {
                _logger.LogWarning("Empty or null form data received");
                return BadTwiMLResponse("Invalid request format.");
            }
            // Log incoming data securely (excluding sensitive information)
            LogFormData(form);
            // Validate required fields
            if (!ValidateRequiredFields(form, out string errorMessage))
            {
                _logger.LogWarning("Missing required fields: {ErrorMessage}", errorMessage);
                return BadTwiMLResponse(errorMessage);
            }
            var message = CreateWhatsAppMessage(form);
            try
            {
                // The response now comes from the WhatsAppService which should handle booking logic
                var response = await _whatsAppService.HandleIncomingMessageAsync(message);
                _logger.LogInformation("Message processed successfully for {From}", message.From);
                return TwiMLResponse(response);
            }
            catch (Exception ex) when (ex is HttpRequestException || ex is TimeoutException)
            {
                _logger.LogError(ex, "Service communication error for {From}", message.From);
                return BadTwiMLResponse("We're experiencing technical difficulties. Please try again shortly.");
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error processing message for {From}", message.From);
                return BadTwiMLResponse("Sorry, we couldn't process your message. Please try again.");
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled error in webhook");
            return BadTwiMLResponse("An unexpected error occurred. Please try again later.");
        }
    }
    private WhatsAppMessage CreateWhatsAppMessage(Dictionary<string, string> form)
    {
        var message = new WhatsAppMessage
        {
            MessageSid = form.GetValueOrDefault("MessageSid", string.Empty),
            From = form.GetValueOrDefault("From", string.Empty),
            To = form.GetValueOrDefault("To", string.Empty),
            Body = form.GetValueOrDefault("Body", string.Empty)
        };
        if (int.TryParse(form.GetValueOrDefault("NumMedia", "0"), out int numMedia))
        {
            message.NumMedia = numMedia;
            ProcessMediaAttachments(form, message, numMedia);
        }
        return message;
    }
    private void ProcessMediaAttachments(Dictionary<string, string> form, WhatsAppMessage message, int numMedia)
    {
        for (int i = 0; i < numMedia; i++)
        {
            var mediaUrl = form.GetValueOrDefault($"MediaUrl{i}");
            var mediaContentType = form.GetValueOrDefault($"MediaContentType{i}");
            if (!string.IsNullOrEmpty(mediaUrl) && !string.IsNullOrEmpty(mediaContentType))
            {
                message.MediaUrls[mediaContentType] = mediaUrl;
                _logger.LogInformation("Media attachment processed - Type: {MediaType}, URL: {MediaUrl}", 
                    mediaContentType, mediaUrl);
            }
        }
    }
    private bool ValidateRequiredFields(Dictionary<string, string> form, out string errorMessage)
    {
        var requiredFields = new[] { "From", "To" };
        var missingFields = requiredFields.Where(field => !form.ContainsKey(field) || string.IsNullOrEmpty(form[field]));
        if (missingFields.Any())
        {
            errorMessage = $"Missing required fields: {string.Join(", ", missingFields)}";
            return false;
        }
        errorMessage = string.Empty;
        return true;
    }
    private void LogFormData(Dictionary<string, string> form)
    {
        var sensitiveKeys = new[] { "AccountSid", "ApiKey", "ApiSecret" };
        foreach (var item in form.Where(x => !sensitiveKeys.Contains(x.Key)))
        {
            _logger.LogInformation("Form data - {Key}: {Value}", 
                item.Key, 
                item.Key.Contains("Media") ? "[Media Content]" : item.Value);
        }
    }
    private IActionResult TwiMLResponse(string message)
    {
        var response = new XDocument(
            new XElement("Response",
                new XElement("Message", new XCData(message))
            ));
        return Content(response.ToString(), "application/xml", Encoding.UTF8);
    }
    private IActionResult BadTwiMLResponse(string errorMessage)
    {
        return TwiMLResponse(errorMessage);
    }
}

Every WhatsApp webhook POST arrives as a URL‑encoded form data containing fields like From, Body, and any MediaUrl{i} entries. You first log and validate that the essential fields (From and To) are present, then map the form into your WhatsAppMessage model, including any attached media. The controller then calls into IWhatsAppService.HandleIncomingMessageAsync, which executes all of our AI, transcription, ticketing, and email logic. Finally, you wrap the string response in TwiML XML so Twilio knows what to send back to the user on WhatsApp.

This controller neatly separates HTTP concerns validation, logging, TwiML formatting from business logic, adhering to the Single Responsibility Principle. With this in place, your Twilio number can forward incoming WhatsApp messages straight to https://<your-domain>/api/WhatsApp, and every text or voice note will be processed end‑to‑end.

Configure Application

The JSON configuration file defines essential API keys, logging settings, and service credentials required for the VoiceToEmail API to function properly. It provides structured environment settings for external services such as OpenAI, SendGrid, Twilio, and AssemblyAI. Locate the file appsettings.json in your VoiceToEmail.API folder. Update the appsettings.json as shown below. Remember to replace the placeholders with real credential values.

For detailed instructions on obtaining API keys and service credentials (OpenAI, SendGrid, Twilio, and AssemblyAI), please refer to the Prerequisites section.

Never expose API keys in public repositories or share them online. To keep your credentials secure: Add appsettings.json to your .gitignore file to prevent accidental uploads. Use environment variables instead of storing sensitive data in plain text for deployment purposes. Consider using a secrets management tool for better security.
{
  "OpenAI": {
    "ApiKey": "OPENAI_API_KEY"
  },
  "SendGrid": {
    "ApiKey": "SENDGRID_API_KEY",
    "FromEmail": "SENDGRID_FROM_EMAIL",
    "FromName": "SENDGRID_FROM_NAME"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "Twilio": {
    "AccountSid": "TWILIO_ACCOUNT_SID",
    "AuthToken": "TWILIO_AUTH_TOKEN",
    "WhatsAppNumber": "TWILIO_WHATSAPP_NUMBER"
  },
  "AssemblyAI": {
    "ApiKey": "ASSEMBLYAI_API_KEY"
  },
  "AllowedHosts": "*"
}

The OpenAI section contains the ApiKey, which is used in ContentService to enhance transcribed text by converting it into a professionally formatted email. The SendGrid section includes an ApiKey for authentication, along with FromEmail and FromName, which define the sender’s email address and display name when sending emails. These settings allow EmailService to manage email delivery reliably.

For logging, the configuration specifies different logging levels. By default, general logs are set to "Information", ensuring key events are recorded, while ASP.NET Core framework logs are filtered to "Warning" to prevent excessive log noise. This improves monitoring and debugging by focusing on important events.

The Twilio section holds credentials for handling WhatsApp messages, including the AccountSid and AuthToken, which authenticate API requests. Additionally, WhatsAppNumber specifies the Twilio WhatsApp sender number used for messaging, allowing seamless integration with WhatsAppService. You can use the phone number of a phone that you have access to with WhatsApp installed. If you want instructions on setting up a registered sender for WhatsApp, review the documentation.

The AssemblyAI section contains an ApiKey required for speech-to-text transcription, enabling TranscriptionService to process voice messages and convert them into text-based content. This API plays a crucial role in ensuring accurate transcriptions before they are formatted and sent via email.

Finally, the AllowedHosts setting is configured as * to permit requests from any domain, which is useful during development. However, in production, this setting may need to be restricted for security reasons.

Next, update the Program.cs with the code below to bundle everything in our application together and bring it to life.

using Microsoft.Extensions.DependencyInjection;
using VoiceToEmail.API.Services;
using VoiceToEmail.Core.Interfaces;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Register HttpClient
builder.Services.AddHttpClient();
builder.Services.AddHttpClient<IAIAgentService, AIAgentService>();
// Register services
builder.Services.AddScoped<ITranscriptionService, TranscriptionService>();
builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<ITicketService, TicketService>();
builder.Services.AddScoped<IAIAgentService, AIAgentService>();
builder.Services.AddScoped<IWhatsAppService, WhatsAppService>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
// Use top-level route registration (fixing ASP0014 warning)
app.MapControllers();
app.Run();

In the Program.cs file, you set up your application’s service container by registering controllers, third‑party HTTP clients, and your core services. You start by adding MVC controllers and Swagger for API exploration, then call AddHttpClient() to enable IHttpClientFactory—a recommended pattern for creating and managing HttpClient instances in .NET Core with built‑in DNS resilience and handler pooling. You also register a typed client for the AIAgentService, so that its HttpClient is preconfigured for the OpenAI base address and headers.

Next, each of your application‑specific services - ITranscriptionService, IEmailService, ITicketService, IAIAgentService, and IWhatsAppService - is added to the container with scoped lifetimes, meaning a new instance is created per HTTP request scope. This minimal‑API style setup, introduced in .NET 6 and streamlined further in .NET 9, collapses what used to be split between Startup.cs and Program.cs into a single, concise entry point for configuring middleware, DI, and routing.

Finally, app.MapControllers() wires up attribute‑based controllers, and app.Run() starts the HTTP server. This configuration ensures that all our services, from transcription to email generation, are available via constructor injection wherever they’re needed.

Testing Your Voice Application

To ensure your Voice Driven WhatsApp Ticket Booking backend is running correctly, start by navigating into the VoiceToEmail.API directory, then build and launch the application with the .NET CLI:

cd VoiceToEmail.API
dotnet build
dotnet run

This will compile the code and start Kestrel, typically listening on http://localhost:5168.

Feel free to change this port in your launch settings.

Because Twilio requires a public webhook URL for WhatsApp messaging, you can expose your local server using ngrok. If you prefer, you can also deploy your API to a publicly accessible domain, on Azure, AWS, or any other hosting provider. Then you can skip the ngrok setup entirely and point your Twilio WhatsApp webhook directly at your live URL.

Assuming you are choosing the ngrok method for now, spin up your ngrok tunnel with the command below:

ngrok http 5168

In the terminal, it displays both an HTTP and an HTTPS forwarding URL, as shown in the screenshot below.

Ngrok

ngrok automatically provides valid TLS certificates for the HTTPS endpoint, satisfying Twilio’s requirement for secure webhooks.

Once ngrok is running, copy the generated HTTPS URL and navigate to your Twilio Console. Navigate to Messaging → Try it Out → Send A WhatsApp Message, and choose the Sandbox Settings tab. Paste the ngrok URL into the “WHEN A MESSAGE COMES IN” field, appending /api/whatsapp to match your controller route.

Webhook endpoint

Once your application is successfully linked to the Twilio WhatsApp Sandbox, you can initiate end-to-end testing of the voice-to-booking functionality. Begin by sending a voice note to your designated sandbox number. The system will process this audio message by transcribing its content, extracting the email address, and dispatching a booking confirmation email through SendGrid.

If the initial voice note lacks a clearly discernible email address, you have two options:

  • Send another voice note that includes the email address.
  • Send a text message containing just the email address.

This ensures that the application captures the necessary information to complete the email delivery process. You can monitor the application's behavior and verify the successful transcription and email dispatch by checking the logs or any provided user interface. Let’s see how the system works. The wait is now over. See the screenshots below.

Ticket Booking Through Whatsapp

On sending a voice note with the desired book details, you are then asked to confirm booking with your email address. Remember to try with what exists in your database.

WhatsApp Successful Booking

Upon successful booking, you receive an email for confirmation and a link to make payment. Note that the payment link is dummy.

Payment Confirmation Email

Finally, you receive an email with the generated pdf ticket that you can download and save.

Generated Ticket

What's Next for Your WhatsApp Voice-to-Email System?

You’ve built a seamless end‑to‑end pipeline that accepts WhatsApp voice notes or texts, uses AI to transcribe and interpret booking requests, checks availability, processes payments via a dummy service, assigns seats, and delivers PDF tickets by email. To evolve this application into a production‑ready platform, consider these next steps:

1. Integrate a Real Payment Gateway

Swap out the DummyPaymentService for a full‑featured payment provider such as Stripe, PayPal, or Adyen. Using Stripe’s .NET SDK, you can create secure checkout sessions, handle webhooks for payment confirmation, and support multiple payment methods—all while offloading PCI compliance to Stripe.

2. Persist Conversation and Booking Data

Replace in‑memory dictionaries with a durable data store like Azure Cosmos DB or AWS DynamoDB. Cosmos DB’s .NET SDK makes it straightforward to define document models for ConversationContext and BookingDetails, query past interactions, and scale globally without sacrificing performance.

3. Add User Authentication & Profiles

Enable customers to register and manage their profiles—saving preferences such as preferred seating or payment methods—and view past bookings. ASP.NET Core Identity with external login providers (Google, Facebook, Microsoft) streamlines OAuth integration, letting users sign in securely and linking their WhatsApp number to a persistent account. Learn more at Microsoft Learn.

Additional Resources for Continued Learning

Jacob Snipes is a seasoned AI Engineer who transforms complex communication challenges into seamless, intelligent solutions. With a strong foundation in full-stack development and a keen eye for innovation, he crafts applications that enhance user experiences and streamline operations. Jacob's work bridges the gap between cutting-edge technology and real-world applications, delivering impactful results across various industries.