<?xml version="1.0" encoding="utf-8" standalone="no"?><feed xmlns="http://www.w3.org/2005/Atom"><subtitle>A blog on software development, gadgets, security and some more</subtitle>

  <title>Volkan Paksoy's Blog</title>
  <link href="https://volkanpaksoy.com/atom.xml" rel="self"/>
  <link href="https://volkanpaksoy.com/"/>
  <updated>2026-08-06T18:33:19+00:00</updated>
  <id>https://volkanpaksoy.com/</id>
  <author>
    <name><![CDATA[Volkan Paksoy]]></name>
    
  </author>
  <generator uri="http://octopress.org/">Octopress</generator>

  
  
  <entry>
    <title type="html"><![CDATA[Implementing a Custom MCP Server with .NET]]></title>
    <link href="https://volkanpaksoy.com/archive/2026/08/05/Implementing-a-Custom-MCP-Server-with-dotNET/"/>
    <updated>2026-08-05T13:20:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2026/08/05/Implementing-a-Custom-MCP-Server-with-dotNET</id>
    <content type="html"><![CDATA[<p>In the previous <a href="/blog/How-to-use-AWS-MCP-Servers-in-Claude-Code/">post</a>, you learned how to use AWS Knowledge MCP Server in Claude Code. That server was hosted remotely by AWS, making it simple to add with just a URL. But what if you need custom functionality specific to your domain or workflow? That’s where building your own MCP server comes in.</p>

<p>In this tutorial, you’ll build a custom Weather MCP Server using .NET 9. More importantly, you’ll implement it with <strong>two different transports</strong>: stdio (for local processes) and HTTP (for remote services). This demonstrates a key MCP principle: your business logic stays the same regardless of how clients connect to it.</p>

<h2 id="what-youll-build">What You’ll Build</h2>
<p>A Weather MCP Server that provides:</p>
<ul>
  <li>Current weather information for any location</li>
  <li>Random temperature generation (mock data for demonstration)</li>
  <li>Two deployment options: local stdio and HTTP endpoint</li>
</ul>

<p>By the end of this tutorial, you’ll understand:</p>
<ul>
  <li>How MCP servers are architected</li>
  <li>The difference between business logic and transport layers</li>
  <li>When to use stdio vs HTTP transports</li>
  <li>How to integrate both types into Claude Code</li>
</ul>

<h2 id="pre-requisites">Pre-requisites</h2>
<p>Before starting, ensure you have:</p>

<h3 id="required-software">Required Software</h3>

<ol>
  <li><strong>.NET 9 SDK</strong>
    <ul>
      <li>Download from <a href="https://dotnet.microsoft.com/download">dotnet.microsoft.com</a></li>
      <li>Verify installation: <code class="language-plaintext highlighter-rouge">dotnet --version</code> (should show 9.x.x)</li>
    </ul>
  </li>
  <li><strong>IDE or Code Editor</strong>
    <ul>
      <li>Visual Studio 2022 (Windows/Mac)</li>
      <li>Visual Studio Code with C# extension</li>
      <li>JetBrains Rider</li>
      <li>Any text editor (vim, nano, etc.)</li>
    </ul>
  </li>
  <li><strong>Claude Code</strong>
    <ul>
      <li>Already installed from previous tutorials</li>
      <li>Verify with: <code class="language-plaintext highlighter-rouge">claude --version</code></li>
    </ul>
  </li>
</ol>

<h2 id="understanding-mcp-server-architecture">Understanding MCP Server Architecture</h2>
<p>Before diving into code, let’s understand how MCP servers are structured. This architectural understanding will make the implementation much clearer.</p>

<h3 id="the-two-layer-design">The Two-Layer Design</h3>
<p>MCP servers follow a clean separation of concerns:</p>

<pre><code class="language-mermaid">graph TB
    subgraph "MCP Server Architecture"
        A[Business Logic Layer] --&gt;|Uses| B[MCP Protocol Layer]
        B --&gt;|Exposes via| C1[Stdio Transport]
        B --&gt;|Exposes via| C2[HTTP Transport]
        B --&gt;|Exposes via| C3[SSE Transport]
    end

    C1 --&gt; D1[Local Process&lt;br/&gt;Claude Code]
    C2 --&gt; D2[Remote HTTP&lt;br/&gt;Any Client]
    C3 --&gt; D2

    style A fill:#2ECC40
    style B fill:#FF851B
    style C1 fill:#0074D9
    style C2 fill:#0074D9
    style C3 fill:#0074D9
</code></pre>

<p><strong>Layer 1: Business Logic</strong></p>
<ul>
  <li>Your domain-specific code (weather data, database queries, API calls, etc.)</li>
  <li>Independent of how clients connect</li>
  <li>Reusable across different transports</li>
</ul>

<p><strong>Layer 2: Transport</strong></p>
<ul>
  <li>Handles communication protocol (stdio, HTTP, SSE)</li>
  <li>Wraps business logic in MCP protocol format</li>
  <li>Manages serialization, requests, and responses</li>
</ul>

<h3 id="why-this-matters">Why This Matters</h3>

<p>With this design, you write your weather logic <strong>once</strong> and expose it through <strong>multiple transports</strong>. Need to add WebSocket support later? Just add another transport wrapper—no changes to business logic.</p>

<p>This is exactly what you’ll implement: one weather service, two transports.</p>

<h2 id="project-setup">Project Setup</h2>
<p>Now you’re ready to start building!</p>

<p>Create a directory for your project:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir </span>weather-mcp-server
<span class="nb">cd </span>weather-mcp-server
</code></pre></div></div>

<h3 id="step-1-create-the-solution-structure">Step 1: Create the Solution Structure</h3>
<p>Let’s create a .NET solution with three projects:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Create solution</span>
dotnet new sln <span class="nt">-n</span> WeatherMcpServer

<span class="c"># Create shared library for business logic</span>
dotnet new classlib <span class="nt">-n</span> WeatherMcp.Core

<span class="c"># Create stdio transport project</span>
dotnet new console <span class="nt">-n</span> WeatherMcp.Stdio

<span class="c"># Create HTTP transport project</span>
dotnet new web <span class="nt">-n</span> WeatherMcp.Http

<span class="c"># Add projects to solution</span>
dotnet sln add WeatherMcp.Core/WeatherMcp.Core.csproj
dotnet sln add WeatherMcp.Stdio/WeatherMcp.Stdio.csproj
dotnet sln add WeatherMcp.Http/WeatherMcp.Http.csproj

<span class="c"># Add project references</span>
dotnet add WeatherMcp.Stdio reference WeatherMcp.Core
dotnet add WeatherMcp.Http reference WeatherMcp.Core
</code></pre></div></div>

<p>Your folder structure should now look like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>weather-mcp-server/
├── WeatherMcpServer.sln
├── WeatherMcp.Core/
│   ├── WeatherMcp.Core.csproj
│   └── Class1.cs
├── WeatherMcp.Stdio/
│   ├── WeatherMcp.Stdio.csproj
│   └── Program.cs
└── WeatherMcp.Http/
    ├── WeatherMcp.Http.csproj
    └── Program.cs
</code></pre></div></div>

<h3 id="step-2-install-required-nuget-packages">Step 2: Install Required NuGet Packages</h3>
<p>You’ll implement the MCP protocol manually using JSON-RPC, giving you full control and understanding. No external MCP packages needed—just .NET built-in JSON serialization.</p>

<h3 id="step-3-implement-the-core-weather-service">Step 3: Implement the Core Weather Service</h3>
<p>Delete <code class="language-plaintext highlighter-rouge">Class1.cs</code> in <code class="language-plaintext highlighter-rouge">WeatherMcp.Core</code> and create these files:</p>

<h3 id="weathermcpcoremodelsweatherdatacs">WeatherMcp.Core/Models/WeatherData.cs</h3>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">WeatherMcp.Core.Models</span><span class="p">;</span>

<span class="k">public</span> <span class="n">record</span> <span class="nf">WeatherData</span><span class="p">(</span>
    <span class="kt">string</span> <span class="n">Location</span><span class="p">,</span>
    <span class="kt">double</span> <span class="n">TemperatureCelsius</span><span class="p">,</span>
    <span class="kt">string</span> <span class="n">Condition</span><span class="p">,</span>
    <span class="kt">int</span> <span class="n">Humidity</span><span class="p">,</span>
    <span class="kt">double</span> <span class="n">WindSpeed</span><span class="p">,</span>
    <span class="n">DateTime</span> <span class="n">Timestamp</span>
<span class="p">)</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="kt">double</span> <span class="n">TemperatureFahrenheit</span> <span class="p">=&gt;</span> <span class="p">(</span><span class="n">TemperatureCelsius</span> <span class="p">*</span> <span class="m">9</span> <span class="p">/</span> <span class="m">5</span><span class="p">)</span> <span class="p">+</span> <span class="m">32</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="weathermcpcoreservicesiweatherservicecs">WeatherMcp.Core/Services/IWeatherService.cs</h3>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">WeatherMcp.Core.Models</span><span class="p">;</span>

<span class="k">namespace</span> <span class="nn">WeatherMcp.Core.Services</span><span class="p">;</span>

<span class="k">public</span> <span class="k">interface</span> <span class="nc">IWeatherService</span>
<span class="p">{</span>
    <span class="n">Task</span><span class="p">&lt;</span><span class="n">WeatherData</span><span class="p">&gt;</span> <span class="nf">GetCurrentWeatherAsync</span><span class="p">(</span><span class="kt">string</span> <span class="n">location</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="weathermcpcoreservicesmockweatherservicecs">WeatherMcp.Core/Services/MockWeatherService.cs</h3>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">WeatherMcp.Core.Models</span><span class="p">;</span>

<span class="k">namespace</span> <span class="nn">WeatherMcp.Core.Services</span><span class="p">;</span>

<span class="k">public</span> <span class="k">class</span> <span class="nc">MockWeatherService</span> <span class="p">:</span> <span class="n">IWeatherService</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">static</span> <span class="k">readonly</span> <span class="kt">string</span><span class="p">[]</span> <span class="n">Conditions</span> <span class="p">=</span>
    <span class="p">{</span>
        <span class="s">"Sunny"</span><span class="p">,</span> <span class="s">"Partly Cloudy"</span><span class="p">,</span> <span class="s">"Cloudy"</span><span class="p">,</span> <span class="s">"Rainy"</span><span class="p">,</span> <span class="s">"Stormy"</span><span class="p">,</span> <span class="s">"Snowy"</span><span class="p">,</span> <span class="s">"Foggy"</span><span class="p">,</span> <span class="s">"Windy"</span>
    <span class="p">};</span>

    <span class="k">private</span> <span class="k">readonly</span> <span class="n">Random</span> <span class="n">_random</span> <span class="p">=</span> <span class="k">new</span><span class="p">();</span>

    <span class="k">public</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">WeatherData</span><span class="p">&gt;</span> <span class="nf">GetCurrentWeatherAsync</span><span class="p">(</span><span class="kt">string</span> <span class="n">location</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="c1">// Generate random but realistic weather data</span>
        <span class="kt">var</span> <span class="n">temperature</span> <span class="p">=</span> <span class="n">_random</span><span class="p">.</span><span class="nf">Next</span><span class="p">(-</span><span class="m">10</span><span class="p">,</span> <span class="m">40</span><span class="p">);</span> <span class="c1">// -10°C to 40°C</span>
        <span class="kt">var</span> <span class="n">condition</span> <span class="p">=</span> <span class="n">Conditions</span><span class="p">[</span><span class="n">_random</span><span class="p">.</span><span class="nf">Next</span><span class="p">(</span><span class="n">Conditions</span><span class="p">.</span><span class="n">Length</span><span class="p">)];</span>
        <span class="kt">var</span> <span class="n">humidity</span> <span class="p">=</span> <span class="n">_random</span><span class="p">.</span><span class="nf">Next</span><span class="p">(</span><span class="m">30</span><span class="p">,</span> <span class="m">100</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">windSpeed</span> <span class="p">=</span> <span class="n">_random</span><span class="p">.</span><span class="nf">NextDouble</span><span class="p">()</span> <span class="p">*</span> <span class="m">30</span><span class="p">;</span> <span class="c1">// 0-30 km/h</span>

        <span class="kt">var</span> <span class="n">weather</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">WeatherData</span><span class="p">(</span>
            <span class="n">Location</span><span class="p">:</span> <span class="n">location</span><span class="p">,</span>
            <span class="n">TemperatureCelsius</span><span class="p">:</span> <span class="n">temperature</span><span class="p">,</span>
            <span class="n">Condition</span><span class="p">:</span> <span class="n">condition</span><span class="p">,</span>
            <span class="n">Humidity</span><span class="p">:</span> <span class="n">humidity</span><span class="p">,</span>
            <span class="n">WindSpeed</span><span class="p">:</span> <span class="n">Math</span><span class="p">.</span><span class="nf">Round</span><span class="p">(</span><span class="n">windSpeed</span><span class="p">,</span> <span class="m">1</span><span class="p">),</span>
            <span class="n">Timestamp</span><span class="p">:</span> <span class="n">DateTime</span><span class="p">.</span><span class="n">UtcNow</span>
        <span class="p">);</span>

        <span class="k">return</span> <span class="n">Task</span><span class="p">.</span><span class="nf">FromResult</span><span class="p">(</span><span class="n">weather</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="weathermcpcoremcpmcpservercs">WeatherMcp.Core/Mcp/McpServer.cs</h3>

<p>This is the core MCP protocol implementation:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">System.Text.Json</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">System.Text.Json.Serialization</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">WeatherMcp.Core.Services</span><span class="p">;</span>

<span class="k">namespace</span> <span class="nn">WeatherMcp.Core.Mcp</span><span class="p">;</span>

<span class="k">public</span> <span class="k">class</span> <span class="nc">McpServer</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">IWeatherService</span> <span class="n">_weatherService</span><span class="p">;</span>

    <span class="k">public</span> <span class="nf">McpServer</span><span class="p">(</span><span class="n">IWeatherService</span> <span class="n">weatherService</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_weatherService</span> <span class="p">=</span> <span class="n">weatherService</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">HandleRequestAsync</span><span class="p">(</span><span class="kt">string</span> <span class="n">jsonRequest</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">try</span>
        <span class="p">{</span>
            <span class="kt">var</span> <span class="n">request</span> <span class="p">=</span> <span class="n">JsonSerializer</span><span class="p">.</span><span class="n">Deserialize</span><span class="p">&lt;</span><span class="n">McpRequest</span><span class="p">&gt;(</span><span class="n">jsonRequest</span><span class="p">);</span>

            <span class="k">if</span> <span class="p">(</span><span class="n">request</span> <span class="p">==</span> <span class="k">null</span><span class="p">)</span>
                <span class="k">return</span> <span class="nf">CreateErrorResponse</span><span class="p">(</span><span class="s">"Invalid request format"</span><span class="p">);</span>

            <span class="k">return</span> <span class="n">request</span><span class="p">.</span><span class="n">Method</span> <span class="k">switch</span>
            <span class="p">{</span>
                <span class="s">"initialize"</span> <span class="p">=&gt;</span> <span class="k">await</span> <span class="nf">HandleInitializeAsync</span><span class="p">(</span><span class="n">request</span><span class="p">),</span>
                <span class="s">"tools/list"</span> <span class="p">=&gt;</span> <span class="nf">HandleToolsList</span><span class="p">(</span><span class="n">request</span><span class="p">),</span>
                <span class="s">"tools/call"</span> <span class="p">=&gt;</span> <span class="k">await</span> <span class="nf">HandleToolCallAsync</span><span class="p">(</span><span class="n">request</span><span class="p">),</span>
                <span class="n">_</span> <span class="p">=&gt;</span> <span class="nf">CreateErrorResponse</span><span class="p">(</span><span class="s">$"Unknown method: </span><span class="p">{</span><span class="n">request</span><span class="p">.</span><span class="n">Method</span><span class="p">}</span><span class="s">"</span><span class="p">)</span>
            <span class="p">};</span>
        <span class="p">}</span>
        <span class="k">catch</span> <span class="p">(</span><span class="n">Exception</span> <span class="n">ex</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">return</span> <span class="nf">CreateErrorResponse</span><span class="p">(</span><span class="s">$"Error processing request: </span><span class="p">{</span><span class="n">ex</span><span class="p">.</span><span class="n">Message</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="k">private</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">HandleInitializeAsync</span><span class="p">(</span><span class="n">McpRequest</span> <span class="n">request</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">new</span>
        <span class="p">{</span>
            <span class="n">jsonrpc</span> <span class="p">=</span> <span class="s">"2.0"</span><span class="p">,</span>
            <span class="n">id</span> <span class="p">=</span> <span class="n">request</span><span class="p">.</span><span class="n">Id</span><span class="p">,</span>
            <span class="n">result</span> <span class="p">=</span> <span class="k">new</span>
            <span class="p">{</span>
                <span class="n">protocolVersion</span> <span class="p">=</span> <span class="s">"2024-11-05"</span><span class="p">,</span>
                <span class="n">capabilities</span> <span class="p">=</span> <span class="k">new</span>
                <span class="p">{</span>
                    <span class="n">tools</span> <span class="p">=</span> <span class="k">new</span> <span class="p">{</span> <span class="p">}</span>
                <span class="p">},</span>
                <span class="n">serverInfo</span> <span class="p">=</span> <span class="k">new</span>
                <span class="p">{</span>
                    <span class="n">name</span> <span class="p">=</span> <span class="s">"weather-mcp-server"</span><span class="p">,</span>
                    <span class="n">version</span> <span class="p">=</span> <span class="s">"1.0.0"</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">};</span>

        <span class="k">return</span> <span class="n">Task</span><span class="p">.</span><span class="nf">FromResult</span><span class="p">(</span><span class="n">JsonSerializer</span><span class="p">.</span><span class="nf">Serialize</span><span class="p">(</span><span class="n">response</span><span class="p">));</span>
    <span class="p">}</span>

    <span class="k">private</span> <span class="kt">string</span> <span class="nf">HandleToolsList</span><span class="p">(</span><span class="n">McpRequest</span> <span class="n">request</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">new</span>
        <span class="p">{</span>
            <span class="n">jsonrpc</span> <span class="p">=</span> <span class="s">"2.0"</span><span class="p">,</span>
            <span class="n">id</span> <span class="p">=</span> <span class="n">request</span><span class="p">.</span><span class="n">Id</span><span class="p">,</span>
            <span class="n">result</span> <span class="p">=</span> <span class="k">new</span>
            <span class="p">{</span>
                <span class="n">tools</span> <span class="p">=</span> <span class="k">new</span><span class="p">[]</span>
                <span class="p">{</span>
                    <span class="k">new</span>
                    <span class="p">{</span>
                        <span class="n">name</span> <span class="p">=</span> <span class="s">"get_current_weather"</span><span class="p">,</span>
                        <span class="n">description</span> <span class="p">=</span> <span class="s">"Get current weather information for a specific location"</span><span class="p">,</span>
                        <span class="n">inputSchema</span> <span class="p">=</span> <span class="k">new</span>
                        <span class="p">{</span>
                            <span class="n">type</span> <span class="p">=</span> <span class="s">"object"</span><span class="p">,</span>
                            <span class="n">properties</span> <span class="p">=</span> <span class="k">new</span>
                            <span class="p">{</span>
                                <span class="n">location</span> <span class="p">=</span> <span class="k">new</span>
                                <span class="p">{</span>
                                    <span class="n">type</span> <span class="p">=</span> <span class="s">"string"</span><span class="p">,</span>
                                    <span class="n">description</span> <span class="p">=</span> <span class="s">"City name or location (e.g., 'London', 'New York', 'Tokyo')"</span>
                                <span class="p">}</span>
                            <span class="p">},</span>
                            <span class="n">required</span> <span class="p">=</span> <span class="k">new</span><span class="p">[]</span> <span class="p">{</span> <span class="s">"location"</span> <span class="p">}</span>
                        <span class="p">}</span>
                    <span class="p">}</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">};</span>

        <span class="k">return</span> <span class="n">JsonSerializer</span><span class="p">.</span><span class="nf">Serialize</span><span class="p">(</span><span class="n">response</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">private</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">HandleToolCallAsync</span><span class="p">(</span><span class="n">McpRequest</span> <span class="n">request</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">request</span><span class="p">.</span><span class="n">Params</span><span class="p">?.</span><span class="n">Name</span> <span class="p">!=</span> <span class="s">"get_current_weather"</span><span class="p">)</span>
            <span class="k">return</span> <span class="nf">CreateErrorResponse</span><span class="p">(</span><span class="s">"Unknown tool"</span><span class="p">);</span>

        <span class="kt">var</span> <span class="n">location</span> <span class="p">=</span> <span class="n">request</span><span class="p">.</span><span class="n">Params</span><span class="p">.</span><span class="n">Arguments</span><span class="p">?.</span><span class="nf">GetProperty</span><span class="p">(</span><span class="s">"location"</span><span class="p">).</span><span class="nf">GetString</span><span class="p">();</span>

        <span class="k">if</span> <span class="p">(</span><span class="kt">string</span><span class="p">.</span><span class="nf">IsNullOrEmpty</span><span class="p">(</span><span class="n">location</span><span class="p">))</span>
            <span class="k">return</span> <span class="nf">CreateErrorResponse</span><span class="p">(</span><span class="s">"Location is required"</span><span class="p">);</span>

        <span class="kt">var</span> <span class="n">weather</span> <span class="p">=</span> <span class="k">await</span> <span class="n">_weatherService</span><span class="p">.</span><span class="nf">GetCurrentWeatherAsync</span><span class="p">(</span><span class="n">location</span><span class="p">);</span>

        <span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">new</span>
        <span class="p">{</span>
            <span class="n">jsonrpc</span> <span class="p">=</span> <span class="s">"2.0"</span><span class="p">,</span>
            <span class="n">id</span> <span class="p">=</span> <span class="n">request</span><span class="p">.</span><span class="n">Id</span><span class="p">,</span>
            <span class="n">result</span> <span class="p">=</span> <span class="k">new</span>
            <span class="p">{</span>
                <span class="n">content</span> <span class="p">=</span> <span class="k">new</span><span class="p">[]</span>
                <span class="p">{</span>
                    <span class="k">new</span>
                    <span class="p">{</span>
                        <span class="n">type</span> <span class="p">=</span> <span class="s">"text"</span><span class="p">,</span>
                        <span class="n">text</span> <span class="p">=</span> <span class="s">$@"Current weather in </span><span class="p">{</span><span class="n">weather</span><span class="p">.</span><span class="n">Location</span><span class="p">}</span><span class="s">:</span><span class="err">
</span><span class="s">Temperature: </span><span class="p">{</span><span class="n">weather</span><span class="p">.</span><span class="n">TemperatureCelsius</span><span class="p">}</span><span class="s">°C (</span><span class="p">{</span><span class="n">weather</span><span class="p">.</span><span class="n">TemperatureFahrenheit</span><span class="p">:</span><span class="n">F1</span><span class="p">}</span><span class="s">°F)</span><span class="err">
</span><span class="s">Condition: </span><span class="p">{</span><span class="n">weather</span><span class="p">.</span><span class="n">Condition</span><span class="p">}</span><span class="err">
</span><span class="s">Humidity: </span><span class="p">{</span><span class="n">weather</span><span class="p">.</span><span class="n">Humidity</span><span class="p">}</span><span class="s">%</span><span class="err">
</span><span class="s">Wind Speed: </span><span class="p">{</span><span class="n">weather</span><span class="p">.</span><span class="n">WindSpeed</span><span class="p">}</span><span class="s"> km/h</span><span class="err">
</span><span class="s">Last Updated: </span><span class="p">{</span><span class="n">weather</span><span class="p">.</span><span class="n">Timestamp</span><span class="p">:</span><span class="n">yyyy</span><span class="p">-</span><span class="n">MM</span><span class="p">-</span><span class="n">dd</span> <span class="n">HH</span><span class="p">:</span><span class="n">mm</span><span class="p">:</span><span class="n">ss</span><span class="p">}</span><span class="s"> UTC"</span>
                    <span class="p">}</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">};</span>

        <span class="k">return</span> <span class="n">JsonSerializer</span><span class="p">.</span><span class="nf">Serialize</span><span class="p">(</span><span class="n">response</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">private</span> <span class="kt">string</span> <span class="nf">CreateErrorResponse</span><span class="p">(</span><span class="kt">string</span> <span class="n">message</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">new</span>
        <span class="p">{</span>
            <span class="n">jsonrpc</span> <span class="p">=</span> <span class="s">"2.0"</span><span class="p">,</span>
            <span class="n">error</span> <span class="p">=</span> <span class="k">new</span>
            <span class="p">{</span>
                <span class="n">code</span> <span class="p">=</span> <span class="p">-</span><span class="m">32603</span><span class="p">,</span>
                <span class="n">message</span>
            <span class="p">}</span>
        <span class="p">};</span>

        <span class="k">return</span> <span class="n">JsonSerializer</span><span class="p">.</span><span class="nf">Serialize</span><span class="p">(</span><span class="n">response</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="k">public</span> <span class="k">class</span> <span class="nc">McpRequest</span>
<span class="p">{</span>
    <span class="p">[</span><span class="nf">JsonPropertyName</span><span class="p">(</span><span class="s">"jsonrpc"</span><span class="p">)]</span>
    <span class="k">public</span> <span class="kt">string</span><span class="p">?</span> <span class="n">JsonRpc</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>

    <span class="p">[</span><span class="nf">JsonPropertyName</span><span class="p">(</span><span class="s">"id"</span><span class="p">)]</span>
    <span class="k">public</span> <span class="kt">object</span><span class="p">?</span> <span class="n">Id</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>

    <span class="p">[</span><span class="nf">JsonPropertyName</span><span class="p">(</span><span class="s">"method"</span><span class="p">)]</span>
    <span class="k">public</span> <span class="kt">string</span><span class="p">?</span> <span class="n">Method</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>

    <span class="p">[</span><span class="nf">JsonPropertyName</span><span class="p">(</span><span class="s">"params"</span><span class="p">)]</span>
    <span class="k">public</span> <span class="n">McpParams</span><span class="p">?</span> <span class="n">Params</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
<span class="p">}</span>

<span class="k">public</span> <span class="k">class</span> <span class="nc">McpParams</span>
<span class="p">{</span>
    <span class="p">[</span><span class="nf">JsonPropertyName</span><span class="p">(</span><span class="s">"name"</span><span class="p">)]</span>
    <span class="k">public</span> <span class="kt">string</span><span class="p">?</span> <span class="n">Name</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>

    <span class="p">[</span><span class="nf">JsonPropertyName</span><span class="p">(</span><span class="s">"arguments"</span><span class="p">)]</span>
    <span class="k">public</span> <span class="n">JsonElement</span><span class="p">?</span> <span class="n">Arguments</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="understanding-the-mcp-protocol-implementation">Understanding the MCP Protocol Implementation</h3>

<p>This code is the heart of your MCP server. Let’s break down what each part does:</p>

<h4 id="the-main-handler-handlerequestasync">The Main Handler: HandleRequestAsync</h4>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">HandleRequestAsync</span><span class="p">(</span><span class="kt">string</span> <span class="n">jsonRequest</span><span class="p">)</span>
</code></pre></div></div>

<p>This is the entry point for all MCP requests. It:</p>
<ol>
  <li><strong>Deserializes</strong> the incoming JSON-RPC request</li>
  <li><strong>Routes</strong> to the appropriate handler based on the <code class="language-plaintext highlighter-rouge">method</code> field</li>
  <li><strong>Returns</strong> a JSON-RPC response</li>
</ol>

<p>The MCP protocol uses three core methods:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">initialize</code> - Handshake when client connects</li>
  <li><code class="language-plaintext highlighter-rouge">tools/list</code> - Client asks “what can you do?”</li>
  <li><code class="language-plaintext highlighter-rouge">tools/call</code> - Client executes a specific tool</li>
</ul>

<h4 id="handleinitializeasync-the-handshake">HandleInitializeAsync: The Handshake</h4>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">HandleInitializeAsync</span><span class="p">(</span><span class="n">McpRequest</span> <span class="n">request</span><span class="p">)</span>
</code></pre></div></div>

<p>When a client first connects, they send an <code class="language-plaintext highlighter-rouge">initialize</code> request. Your response tells them:</p>
<ul>
  <li><strong>Protocol version</strong>: <code class="language-plaintext highlighter-rouge">2024-11-05</code> (current MCP spec version)</li>
  <li><strong>Capabilities</strong>: What features you support (in this case, just <code class="language-plaintext highlighter-rouge">tools</code>)</li>
  <li><strong>Server info</strong>: Your server’s name and version</li>
</ul>

<p>This is like a handshake - the client learns what your server can do before making any requests.</p>

<h4 id="handletoolslist-advertising-your-tools">HandleToolsList: Advertising Your Tools</h4>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="kt">string</span> <span class="nf">HandleToolsList</span><span class="p">(</span><span class="n">McpRequest</span> <span class="n">request</span><span class="p">)</span>
</code></pre></div></div>

<p>This is where you <strong>advertise your tool</strong> to clients. Remember from the AWS MCP post how the AI knew what tool to use? This is where that magic happens!</p>

<p>Each tool advertisement includes:</p>
<ul>
  <li><strong>name</strong>: The tool identifier (<code class="language-plaintext highlighter-rouge">get_current_weather</code>)</li>
  <li><strong>description</strong>: What the tool does (this is what the AI reads!)</li>
  <li><strong>inputSchema</strong>: JSON Schema defining required parameters</li>
</ul>

<p>The description is crucial - it’s how the AI decides when to use your tool. Make it clear and specific!</p>

<h4 id="handletoolcallasync-doing-the-work">HandleToolCallAsync: Doing the Work</h4>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">HandleToolCallAsync</span><span class="p">(</span><span class="n">McpRequest</span> <span class="n">request</span><span class="p">)</span>
</code></pre></div></div>

<p>When the AI decides to use your tool, this method:</p>
<ol>
  <li><strong>Validates</strong> the tool name matches (<code class="language-plaintext highlighter-rouge">get_current_weather</code>)</li>
  <li><strong>Extracts</strong> the location parameter from the request</li>
  <li><strong>Calls</strong> your weather service to get the data</li>
  <li><strong>Formats</strong> the response according to MCP spec</li>
</ol>

<p>The response must follow the MCP format:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"jsonrpc"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2.0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="err">&lt;request_id&gt;</span><span class="p">,</span><span class="w">
  </span><span class="nl">"result"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"content"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"text"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"&lt;your formatted response&gt;"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">content</code> array can contain multiple items (text, images, resources, etc.). For simplicity, we’re just returning formatted text.</p>

<h4 id="error-handling">Error Handling</h4>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="kt">string</span> <span class="nf">CreateErrorResponse</span><span class="p">(</span><span class="kt">string</span> <span class="n">message</span><span class="p">)</span>
</code></pre></div></div>

<p>When something goes wrong (invalid tool, missing parameter, etc.), you return a JSON-RPC error response with:</p>
<ul>
  <li><strong>code</strong>: <code class="language-plaintext highlighter-rouge">-32603</code> (Internal error code from JSON-RPC spec)</li>
  <li><strong>message</strong>: What went wrong</li>
</ul>

<h4 id="the-request-models">The Request Models</h4>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">class</span> <span class="nc">McpRequest</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">McpParams</span>
</code></pre></div></div>

<p>These classes map JSON-RPC requests to C# objects. Key points:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">JsonPropertyName</code> attributes map JSON field names to C# properties</li>
  <li><code class="language-plaintext highlighter-rouge">JsonElement?</code> for <code class="language-plaintext highlighter-rouge">Arguments</code> because we don’t know the structure ahead of time</li>
  <li>Nullable types (<code class="language-plaintext highlighter-rouge">string?</code>, <code class="language-plaintext highlighter-rouge">object?</code>) handle optional fields</li>
</ul>

<h4 id="how-it-all-flows-together">How It All Flows Together</h4>

<p>Here’s a typical conversation:</p>

<ol>
  <li><strong>Client connects</strong>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05",...}}
</code></pre></div>    </div>
  </li>
  <li><strong>Client asks what tools exist</strong>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>→ {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
← {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"get_current_weather",...}]}}
</code></pre></div>    </div>
  </li>
  <li><strong>AI reads tool descriptions and decides to call your tool</strong>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>→ {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_current_weather","arguments":{"location":"London"}}}
← {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Current weather in London..."}]}}
</code></pre></div>    </div>
  </li>
</ol>

<p>This is JSON-RPC over MCP - your transport layer (stdio or HTTP) handles the communication, while this class handles the protocol logic.</p>

<p>The core business logic is now complete! You now have:</p>
<ul>
  <li>Weather data models</li>
  <li>Mock weather service</li>
  <li>MCP protocol handler</li>
</ul>

<h2 id="step-4-implement-stdio-transport">Step 4: Implement Stdio Transport</h2>

<p>Now wrap your weather service in a stdio transport. Replace the contents of <code class="language-plaintext highlighter-rouge">WeatherMcp.Stdio/Program.cs</code>:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">System.Text</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">WeatherMcp.Core.Mcp</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">WeatherMcp.Core.Services</span><span class="p">;</span>

<span class="c1">// Create weather service and MCP server</span>
<span class="kt">var</span> <span class="n">weatherService</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">MockWeatherService</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">mcpServer</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">McpServer</span><span class="p">(</span><span class="n">weatherService</span><span class="p">);</span>

<span class="c1">// Read from stdin, write to stdout (stdio transport)</span>
<span class="c1">// IMPORTANT: Use UTF8 without BOM to avoid JSON parsing issues</span>
<span class="kt">var</span> <span class="n">utf8NoBom</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">UTF8Encoding</span><span class="p">(</span><span class="k">false</span><span class="p">);</span>
<span class="k">using</span> <span class="nn">var</span> <span class="n">reader</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">StreamReader</span><span class="p">(</span><span class="n">Console</span><span class="p">.</span><span class="nf">OpenStandardInput</span><span class="p">(),</span> <span class="n">utf8NoBom</span><span class="p">);</span>
<span class="k">using</span> <span class="nn">var</span> <span class="n">writer</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">StreamWriter</span><span class="p">(</span><span class="n">Console</span><span class="p">.</span><span class="nf">OpenStandardOutput</span><span class="p">(),</span> <span class="n">utf8NoBom</span><span class="p">)</span> <span class="p">{</span> <span class="n">AutoFlush</span> <span class="p">=</span> <span class="k">true</span> <span class="p">};</span>

<span class="k">try</span>
<span class="p">{</span>
    <span class="k">while</span> <span class="p">(</span><span class="k">true</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">line</span> <span class="p">=</span> <span class="k">await</span> <span class="n">reader</span><span class="p">.</span><span class="nf">ReadLineAsync</span><span class="p">();</span>

        <span class="k">if</span> <span class="p">(</span><span class="n">line</span> <span class="p">==</span> <span class="k">null</span><span class="p">)</span>
            <span class="k">break</span><span class="p">;</span> <span class="c1">// EOF reached</span>

        <span class="k">if</span> <span class="p">(</span><span class="kt">string</span><span class="p">.</span><span class="nf">IsNullOrWhiteSpace</span><span class="p">(</span><span class="n">line</span><span class="p">))</span>
            <span class="k">continue</span><span class="p">;</span>

        <span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">await</span> <span class="n">mcpServer</span><span class="p">.</span><span class="nf">HandleRequestAsync</span><span class="p">(</span><span class="n">line</span><span class="p">);</span>

        <span class="k">await</span> <span class="n">writer</span><span class="p">.</span><span class="nf">WriteLineAsync</span><span class="p">(</span><span class="n">response</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
<span class="k">catch</span> <span class="p">(</span><span class="n">Exception</span><span class="p">)</span>
<span class="p">{</span>
    <span class="c1">// Silently handle errors - stderr logging can interfere with Claude Code</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>Important Notes:</strong></p>

<ol>
  <li>
    <p><strong>UTF-8 without BOM</strong>: The <code class="language-plaintext highlighter-rouge">new UTF8Encoding(false)</code> is critical. By default, .NET’s UTF-8 encoding includes a Byte Order Mark (BOM) which breaks JSON parsing. Claude Code expects pure JSON output without any BOM.</p>
  </li>
  <li>
    <p><strong>No stderr logging</strong>: We removed all <code class="language-plaintext highlighter-rouge">Console.Error</code> logging because Claude Code monitors stderr for errors. Any output to stderr can cause connection issues or timeouts.</p>
  </li>
  <li>
    <p><strong>Silent error handling</strong>: Errors are caught but not logged. In production, you might want to log to a file instead.</p>
  </li>
</ol>

<h3 id="test-the-stdio-server">Test the Stdio Server</h3>

<p>Build and test the stdio server:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Build the project</span>
dotnet build WeatherMcp.Stdio

<span class="c"># Test it manually</span>
<span class="nb">cd </span>WeatherMcp.Stdio/bin/Debug/net9.0

<span class="c"># Run and send a test request</span>
<span class="nb">echo</span> <span class="s1">'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'</span> | ./WeatherMcp.Stdio
</code></pre></div></div>

<p>You should see the initialization response!</p>

<h2 id="step-5-implement-http-transport">Step 5: Implement HTTP Transport</h2>

<p>Now create an HTTP endpoint. Replace <code class="language-plaintext highlighter-rouge">WeatherMcp.Http/Program.cs</code>:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">WeatherMcp.Core.Mcp</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">WeatherMcp.Core.Services</span><span class="p">;</span>

<span class="kt">var</span> <span class="n">builder</span> <span class="p">=</span> <span class="n">WebApplication</span><span class="p">.</span><span class="nf">CreateBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">);</span>

<span class="c1">// Register services</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddSingleton</span><span class="p">&lt;</span><span class="n">IWeatherService</span><span class="p">,</span> <span class="n">MockWeatherService</span><span class="p">&gt;();</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddSingleton</span><span class="p">&lt;</span><span class="n">McpServer</span><span class="p">&gt;();</span>

<span class="c1">// Configure CORS for development</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddCors</span><span class="p">(</span><span class="n">options</span> <span class="p">=&gt;</span>
<span class="p">{</span>
    <span class="n">options</span><span class="p">.</span><span class="nf">AddDefaultPolicy</span><span class="p">(</span><span class="n">policy</span> <span class="p">=&gt;</span>
    <span class="p">{</span>
        <span class="n">policy</span><span class="p">.</span><span class="nf">AllowAnyOrigin</span><span class="p">()</span>
              <span class="p">.</span><span class="nf">AllowAnyMethod</span><span class="p">()</span>
              <span class="p">.</span><span class="nf">AllowAnyHeader</span><span class="p">();</span>
    <span class="p">});</span>
<span class="p">});</span>

<span class="kt">var</span> <span class="n">app</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">Build</span><span class="p">();</span>

<span class="n">app</span><span class="p">.</span><span class="nf">UseCors</span><span class="p">();</span>

<span class="c1">// MCP endpoint</span>
<span class="n">app</span><span class="p">.</span><span class="nf">MapPost</span><span class="p">(</span><span class="s">"/mcp"</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span><span class="n">HttpContext</span> <span class="n">context</span><span class="p">,</span> <span class="n">McpServer</span> <span class="n">mcpServer</span><span class="p">)</span> <span class="p">=&gt;</span>
<span class="p">{</span>
    <span class="k">using</span> <span class="nn">var</span> <span class="n">reader</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">StreamReader</span><span class="p">(</span><span class="n">context</span><span class="p">.</span><span class="n">Request</span><span class="p">.</span><span class="n">Body</span><span class="p">);</span>
    <span class="kt">var</span> <span class="n">requestBody</span> <span class="p">=</span> <span class="k">await</span> <span class="n">reader</span><span class="p">.</span><span class="nf">ReadToEndAsync</span><span class="p">();</span>

    <span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">await</span> <span class="n">mcpServer</span><span class="p">.</span><span class="nf">HandleRequestAsync</span><span class="p">(</span><span class="n">requestBody</span><span class="p">);</span>

    <span class="n">context</span><span class="p">.</span><span class="n">Response</span><span class="p">.</span><span class="n">ContentType</span> <span class="p">=</span> <span class="s">"application/json"</span><span class="p">;</span>
    <span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="n">Response</span><span class="p">.</span><span class="nf">WriteAsync</span><span class="p">(</span><span class="n">response</span><span class="p">);</span>
<span class="p">});</span>

<span class="c1">// Health check endpoint</span>
<span class="n">app</span><span class="p">.</span><span class="nf">MapGet</span><span class="p">(</span><span class="s">"/health"</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="n">Results</span><span class="p">.</span><span class="nf">Ok</span><span class="p">(</span><span class="k">new</span> <span class="p">{</span> <span class="n">status</span> <span class="p">=</span> <span class="s">"healthy"</span><span class="p">,</span> <span class="n">service</span> <span class="p">=</span> <span class="s">"weather-mcp-server"</span> <span class="p">}));</span>

<span class="c1">// Root endpoint with instructions</span>
<span class="n">app</span><span class="p">.</span><span class="nf">MapGet</span><span class="p">(</span><span class="s">"/"</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="n">Results</span><span class="p">.</span><span class="nf">Ok</span><span class="p">(</span><span class="k">new</span>
<span class="p">{</span>
    <span class="n">message</span> <span class="p">=</span> <span class="s">"Weather MCP Server"</span><span class="p">,</span>
    <span class="n">version</span> <span class="p">=</span> <span class="s">"1.0.0"</span><span class="p">,</span>
    <span class="n">endpoints</span> <span class="p">=</span> <span class="k">new</span>
    <span class="p">{</span>
        <span class="n">mcp</span> <span class="p">=</span> <span class="s">"/mcp (POST)"</span><span class="p">,</span>
        <span class="n">health</span> <span class="p">=</span> <span class="s">"/health (GET)"</span>
    <span class="p">},</span>
    <span class="n">example</span> <span class="p">=</span> <span class="s">"POST to /mcp with MCP JSON-RPC requests"</span>
<span class="p">}));</span>

<span class="n">app</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="s">"http://localhost:3000"</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="test-the-http-server">Test the HTTP Server</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Build and run</span>
<span class="nb">cd </span>WeatherMcp.Http
dotnet run
</code></pre></div></div>

<p>In another terminal, test it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Test health endpoint</span>
curl http://localhost:3000/health

<span class="c"># Test MCP initialization</span>
curl <span class="nt">-X</span> POST http://localhost:3000/mcp <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'</span>

<span class="c"># Test weather tool</span>
curl <span class="nt">-X</span> POST http://localhost:3000/mcp <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_current_weather","arguments":{"location":"London"}}}'</span>
</code></pre></div></div>

<p>Once you’ve ensured both transports are working, move on to the next step.</p>

<h2 id="step-6-add-stdio-server-to-claude-code">Step 6: Add Stdio Server to Claude Code</h2>
<p>Now integrate the stdio version with Claude Code.</p>

<h3 id="build-a-release-version">Build a Release Version</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd </span>weather-mcp-server
dotnet publish WeatherMcp.Stdio <span class="nt">-c</span> Release <span class="nt">-o</span> ./publish/stdio
</code></pre></div></div>

<p>This creates a <code class="language-plaintext highlighter-rouge">.dll</code> file that you’ll run using the <code class="language-plaintext highlighter-rouge">dotnet</code> command.</p>

<p><strong>Why .dll instead of standalone executable?</strong>
By default, <code class="language-plaintext highlighter-rouge">dotnet publish</code> creates a framework-dependent deployment (<code class="language-plaintext highlighter-rouge">.dll</code> file) that requires the .NET runtime. This is the standard approach most .NET developers are familiar with. It’s smaller, faster to publish, and leverages the .NET SDK already installed on your machine.</p>

<h3 id="add-to-claude-code">Add to Claude Code</h3>

<p>Since .NET publish creates a <code class="language-plaintext highlighter-rouge">.dll</code> (not a standalone executable), you need to configure Claude Code to run it with the <code class="language-plaintext highlighter-rouge">dotnet</code> command.</p>

<p><strong>Option 1: Using Claude Code CLI</strong></p>

<p>Unfortunately, <code class="language-plaintext highlighter-rouge">claude mcp add</code> doesn’t support complex command structures like <code class="language-plaintext highlighter-rouge">dotnet &lt;dll&gt;</code>, so you’ll need to configure it manually.</p>

<p><strong>Option 2: Manual Configuration (Recommended)</strong></p>

<p>Edit your user-scoped config file:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nano ~/.claude.json
</code></pre></div></div>

<p>Add the weather-stdio server to the <code class="language-plaintext highlighter-rouge">mcpServers</code> section:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"aws-knowledge"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://knowledge-mcp.global.api.aws"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"weather-stdio"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"stdio"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"dotnet"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"/absolute/path/to/weather-mcp-server/publish/stdio/WeatherMcp.Stdio.dll"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"env"</span><span class="p">:</span><span class="w"> </span><span class="p">{}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Important:</strong> Replace <code class="language-plaintext highlighter-rouge">/absolute/path/to/</code> with your actual path. To get it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd </span>weather-mcp-server/publish/stdio
<span class="nb">pwd</span>
</code></pre></div></div>

<p>For example, it might look like:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"weather-stdio"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"stdio"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"dotnet"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"/Users/yourname/weather-mcp-server/publish/stdio/WeatherMcp.Stdio.dll"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"env"</span><span class="p">:</span><span class="w"> </span><span class="p">{}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Save and exit (Ctrl+X, then Y, then Enter in nano).</p>

<h3 id="verify-it-works">Verify It Works</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Check MCP servers</span>
claude mcp list

<span class="c"># Should show: weather-stdio - ✓ Connected</span>
</code></pre></div></div>

<p>Start Claude Code and ask:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>What's the weather in Sydney?
</code></pre></div></div>

<p>Claude Code should use your custom weather MCP server!</p>

<h2 id="step-7-add-http-server-to-claude-code">Step 7: Add HTTP Server to Claude Code</h2>

<p>Now add the HTTP version. Since both stdio and HTTP servers provide the same tool (<code class="language-plaintext highlighter-rouge">get_current_weather</code>), we’ll temporarily remove the stdio server to clearly test the HTTP version.</p>

<h3 id="remove-stdio-server">Remove Stdio Server</h3>
<p>Edit <code class="language-plaintext highlighter-rouge">~/.claude.json</code> and comment out or remove the <code class="language-plaintext highlighter-rouge">weather-stdio</code> entry:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nano ~/.claude.json
</code></pre></div></div>

<p>Remove or comment out the weather-stdio section so it looks like this:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"aws-knowledge"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://knowledge-mcp.global.api.aws"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Save and exit (Ctrl+X, then Y, then Enter).</p>

<p>Verify it’s removed:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>claude mcp list
</code></pre></div></div>

<p>You should only see <code class="language-plaintext highlighter-rouge">aws-knowledge</code> now.</p>

<h3 id="start-the-http-server">Start the HTTP Server</h3>
<p>In one terminal:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd </span>WeatherMcp.Http
dotnet run
</code></pre></div></div>

<p>This runs on <code class="language-plaintext highlighter-rouge">http://localhost:3000</code>.</p>

<h3 id="add-to-claude-code-1">Add to Claude Code</h3>
<p>In another terminal:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>claude mcp add <span class="nt">--transport</span> http weather-http <span class="nt">--scope</span> user http://localhost:3000/mcp
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">--scope user</code> flag makes the HTTP server available globally, just like the stdio version.</p>

<h3 id="alternative-manual-configuration">Alternative: Manual Configuration</h3>

<p>If configuring manually, edit <code class="language-plaintext highlighter-rouge">~/.claude.json</code>:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"weather-stdio"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"stdio"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"dotnet"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"/absolute/path/to/WeatherMcp.Stdio.dll"</span><span class="p">],</span><span class="w">
      </span><span class="nl">"env"</span><span class="p">:</span><span class="w"> </span><span class="p">{}</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"weather-http"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://localhost:3000/mcp"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="verify-both-work">Verify Both Work</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>claude mcp list

<span class="c"># Should show:</span>
<span class="c"># weather-stdio - ✓ Connected</span>
<span class="c"># weather-http - ✓ Connected</span>
</code></pre></div></div>

<p>Now you have the same weather service exposed through two different transports!</p>

<h2 id="when-to-use-which-transport">When to Use Which Transport</h2>

<p>Now that you’ve built both, when should you use each?</p>

<h3 id="use-stdio-transport-when">Use Stdio Transport When:</h3>

<p>✅ <strong>Local development and testing</strong></p>
<ul>
  <li>Fast iteration, no network overhead</li>
  <li>Easy debugging with stderr logs</li>
  <li>Direct process communication</li>
</ul>

<p>✅ <strong>Personal tools and scripts</strong></p>
<ul>
  <li>No need for remote access</li>
  <li>Simpler deployment (single executable)</li>
  <li>Lower resource usage</li>
</ul>

<p>✅ <strong>Security-sensitive operations</strong></p>
<ul>
  <li>Data never leaves your machine</li>
  <li>No network exposure</li>
  <li>Direct file system access</li>
</ul>

<p><strong>Example Use Cases:</strong></p>
<ul>
  <li>Local file system MCP server</li>
  <li>Database query tools</li>
  <li>Personal automation scripts</li>
  <li>Development utilities</li>
</ul>

<h3 id="use-http-transport-when">Use HTTP Transport When:</h3>

<p>✅ <strong>Team collaboration</strong></p>
<ul>
  <li>Multiple developers share one server</li>
  <li>Centralized data source</li>
  <li>Consistent results across team</li>
</ul>

<p>✅ <strong>Remote services</strong></p>
<ul>
  <li>MCP server on different machine</li>
  <li>Cloud-hosted services</li>
  <li>Containerized deployments</li>
</ul>

<p>✅ <strong>Third-party integrations</strong></p>
<ul>
  <li>Public APIs need HTTP</li>
  <li>Integration with existing web services</li>
  <li>Cross-platform compatibility</li>
</ul>

<p>✅ <strong>Scalability requirements</strong></p>
<ul>
  <li>Load balancing</li>
  <li>Multiple instances</li>
  <li>High availability</li>
</ul>

<p><strong>Example Use Cases:</strong></p>
<ul>
  <li>Shared company knowledge base</li>
  <li>Cloud-based data services</li>
  <li>External API wrappers</li>
  <li>Production services</li>
</ul>

<h3 id="comparison-table">Comparison Table</h3>

<table>
  <thead>
    <tr>
      <th>Feature</th>
      <th>Stdio</th>
      <th>HTTP</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Setup Complexity</td>
      <td>Simple</td>
      <td>Moderate</td>
    </tr>
    <tr>
      <td>Network Required</td>
      <td>No</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Remote Access</td>
      <td>No</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Performance</td>
      <td>Fastest</td>
      <td>Network latency</td>
    </tr>
    <tr>
      <td>Security</td>
      <td>Local only</td>
      <td>Authentication needed</td>
    </tr>
    <tr>
      <td>Scalability</td>
      <td>One instance</td>
      <td>Load balanceable</td>
    </tr>
    <tr>
      <td>Debugging</td>
      <td>Direct logs</td>
      <td>HTTP tools/logging</td>
    </tr>
    <tr>
      <td>Best For</td>
      <td>Local tools</td>
      <td>Shared services</td>
    </tr>
  </tbody>
</table>

<h2 id="conclusion">Conclusion</h2>

<p>Congratulations! You’ve built a complete custom MCP server from scratch using .NET 9. More importantly, you’ve learned the fundamental architectural principle of MCP: <strong>separation of business logic from transport</strong>.</p>

<p>If you didn’t follow along, you can download the source code of the final version <a href="/assets/downloads/blog-samples-main.zip">here</a> (the project is in the <code class="language-plaintext highlighter-rouge">weather-mcp-server</code> folder)</p>

<h3 id="what-youve-accomplished">What You’ve Accomplished</h3>

<ol>
  <li><strong>Built a reusable weather service</strong> - Core business logic that’s transport-agnostic</li>
  <li><strong>Implemented stdio transport</strong> - For fast local development and personal tools</li>
  <li><strong>Implemented HTTP transport</strong> - For remote access and team collaboration</li>
  <li><strong>Integrated with Claude Code</strong> - Made your custom tools accessible to AI</li>
  <li><strong>Understood when to use each</strong> - Made informed architecture decisions</li>
</ol>

<h3 id="key-takeaways">Key Takeaways</h3>

<ul>
  <li><strong>MCP is transport-agnostic</strong> - Write once, deploy many ways</li>
  <li><strong>Stdio is fast and simple</strong> - Perfect for local tools</li>
  <li><strong>HTTP enables sharing</strong> - Ideal for team services</li>
  <li><strong>Protocol is straightforward</strong> - JSON-RPC makes it easy to implement</li>
  <li><strong>Custom servers unlock potential</strong> - AI can access your unique data and services</li>
</ul>

<h3 id="next-steps">Next Steps</h3>

<p>Now that you understand the fundamentals, consider:</p>

<ol>
  <li><strong>Add real data sources</strong> - Replace mock weather with actual APIs</li>
  <li><strong>Implement authentication</strong> - Secure your HTTP server</li>
  <li><strong>Add more tools</strong> - Forecast, historical data, alerts</li>
  <li><strong>Deploy to production</strong> - Docker, Kubernetes, cloud platforms</li>
  <li><strong>Build domain-specific servers</strong> - Database tools, CI/CD integrations, business logic</li>
</ol>

<p>The MCP ecosystem is growing rapidly. By understanding how to build custom servers, you can create AI-accessible tools tailored to your specific needs.</p>

<h2 id="resources">Resources</h2>
<ul>
  <li><a href="/assets/downloads/blog-samples-main.zip">Demo source code</a> - Source code for the demo application (zip, project in the <code class="language-plaintext highlighter-rouge">weather-mcp-server</code> folder)</li>
  <li><a href="https://spec.modelcontextprotocol.io/">Model Context Protocol Specification</a> - Official MCP protocol specification</li>
  <li><a href="https://github.com/modelcontextprotocol/typescript-sdk">MCP TypeScript SDK</a> - Reference implementation</li>
  <li><a href="https://docs.claude.com/en/docs/claude-code/mcp">Claude Code MCP Documentation</a> - Integration guide</li>
  <li><a href="https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-9">.NET 9 Documentation</a> - Official .NET documentation</li>
  <li><a href="https://www.jsonrpc.org/specification">JSON-RPC 2.0 Specification</a> - Understanding the underlying protocol</li>
  <li><a href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis">ASP.NET Core Minimal APIs</a> - Building HTTP endpoints</li>
  <li><a href="https://github.com/modelcontextprotocol">MCP Community Examples</a> - More MCP server examples</li>
</ul>
]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[How to Use AWS MCP Servers in Claude Code]]></title>
    <link href="https://volkanpaksoy.com/archive/2026/08/05/How-to-Use-AWS-MCP-Servers-in-Claude-Code/"/>
    <updated>2026-08-05T13:15:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2026/08/05/How-to-Use-AWS-MCP-Servers-in-Claude-Code</id>
    <content type="html"><![CDATA[<p>In the previous post, we took a detailed look at <a href="/blog/MCP-explained/">Model Context Protocol (MCP)</a>. Now it’s time to work on something more practical and utilize that knowledge. AWS has released a comprehensive suite of MCP servers, and since AWS is a ubiquitous cloud provider, I thought it would be a good exercise to integrate them into my workflow when using Claude Code.</p>

<p>AWS provides over 60 MCP servers covering everything from documentation and infrastructure management to AI/ML services and cost analysis. In this tutorial, we’ll focus on the AWS Knowledge MCP Server which <a href="https://aws.amazon.com/about-aws/whats-new/2025/10/aws-knowledge-mcp-server-generally-available/">recently became generally available</a>.</p>

<h2 id="what-is-aws-knowledge-mcp-server">What is AWS Knowledge MCP Server?</h2>
<p>AWS Knowledge MCP Server is a fully managed remote service that provides AI agents and MCP clients with instant access to authoritative AWS information in LLM-compatible format. Think of it as having AWS’s entire knowledge base—documentation, API references, blog posts, What’s New announcements, and Well-Architected best practices—directly accessible to your AI assistant.</p>

<h3 id="key-features">Key Features</h3>

<p><strong>Comprehensive AWS Knowledge Access:</strong></p>
<ul>
  <li>Official AWS documentation</li>
  <li>API references for all AWS services</li>
  <li>What’s New announcements</li>
  <li>Well-Architected Framework best practices</li>
  <li>AWS Builder Library content</li>
  <li>AWS blog posts</li>
  <li>Regional availability of AWS APIs and CloudFormation resources</li>
</ul>

<p><strong>Fully Managed:</strong></p>
<ul>
  <li>No setup or infrastructure management required</li>
  <li>Publicly accessible at no cost</li>
  <li>No AWS account required</li>
  <li>Available globally</li>
  <li>Subject to rate limits</li>
</ul>

<p><strong>Benefits:</strong></p>
<ol>
  <li><strong>Accuracy</strong> - AI responses anchored in trusted AWS context</li>
  <li><strong>Consistency</strong> - Reliable execution based on official guidance</li>
  <li><strong>Efficiency</strong> - No manual context management or copy-pasting from docs</li>
</ol>

<h2 id="setting-up-aws-knowledge-mcp-server-in-claude-code">Setting Up AWS Knowledge MCP Server in Claude Code</h2>
<p>Before you start, to get a baseline, run the following command to see what MCP servers you have configured. By default you shouldn’t see anything.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>claude mcp list
</code></pre></div></div>

<p>In a default Claude Code installation, your output should look similar to this:</p>

<p><img src="/images/vpblogimg/2025/10/aws-mcp/aws-mcp-list-before.png" alt="" /></p>

<p>Now, proceed to add the AWS Knowledge MCP Server.</p>

<h3 id="adding-the-server">Adding the Server</h3>
<p>One of the beauties of AWS Knowledge MCP Server is its simplicity—it’s a remote HTTP service that requires minimal configuration.</p>

<p>First, exit Claude Code by running the following command in the terminal:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">exit</span>
</code></pre></div></div>

<p>Then, use the Claude Code CLI to add the server:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>claude mcp add <span class="nt">--transport</span> http aws-knowledge https://knowledge-mcp.global.api.aws
</code></pre></div></div>

<p>This adds the server to your local scope (project-specific). If you want it available across all your projects:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>claude mcp add <span class="nt">--transport</span> http aws-knowledge https://knowledge-mcp.global.api.aws <span class="nt">--scope</span> user
</code></pre></div></div>

<h3 id="alternative-manual-configuration">Alternative: Manual Configuration</h3>
<p>You can also add it directly to your project’s <code class="language-plaintext highlighter-rouge">.mcp.json</code> file:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"aws-knowledge"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://knowledge-mcp.global.api.aws"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="verifying-the-connection">Verifying the Connection</h3>
<p>Check that the server is properly configured:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>claude mcp list
</code></pre></div></div>

<p>Your output should look like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Checking MCP server health...

aws-knowledge: https://knowledge-mcp.global.api.aws (HTTP) - ✓ Connected
</code></pre></div></div>

<p>You can also verify within Claude Code using the <code class="language-plaintext highlighter-rouge">/mcp</code> command.</p>

<p><img src="/images/vpblogimg/2025/10/aws-mcp/aws-mcp-list-in-claude.png" alt="" /></p>

<h3 id="testing-the-connection">Testing the Connection</h3>
<p>To test that the MCP server is actually being used by Claude Code, ask an AWS-related question such as:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>What are the current AWS Lambda timeout limits?
</code></pre></div></div>

<p>And the output should show that Claude Code knows what tool to use to get this data:</p>

<p><img src="/images/vpblogimg/2025/10/aws-mcp/aws-knowledge-in-use.png" alt="" /></p>

<p>And when you proceed, you should get the answer directly coming from the AWS MCP Server:</p>

<p><img src="/images/vpblogimg/2025/10/aws-mcp/aws-mcp-query-response.png" alt="" /></p>

<h2 id="how-does-claude-know-what-tool-to-use">How Does Claude Know What Tool to Use?</h2>
<p>It’s great that when you ask an AWS-related question, it goes straight to the AWS Knowledge MCP server and gets the answer from the correct resource. But how does it know where to find the correct answer? What if I have an Azure MCP server installed as well, or other tools? How would it know which one to choose?</p>

<h3 id="the-mcp-discovery-process">The MCP Discovery Process</h3>
<p>When an MCP server connects to Claude Code, it doesn’t just sit there waiting. It actively <strong>advertises its capabilities</strong> through the Model Context Protocol. This is called the “capability discovery” phase, and it happens automatically when the server is initialized.</p>

<p>Here’s what happens behind the scenes:</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant CC as Claude Code
    participant MCP as MCP Server&lt;br/&gt;(AWS Knowledge)
    participant AI as AI Assistant

    CC-&gt;&gt;MCP: Initialize connection
    MCP-&gt;&gt;CC: Server info &amp; capabilities
    Note over MCP,CC: Server advertises:&lt;br/&gt;- Available tools&lt;br/&gt;- Tool descriptions&lt;br/&gt;- Required parameters
    CC-&gt;&gt;MCP: Request tool list
    MCP-&gt;&gt;CC: Tools: search_documentation,&lt;br/&gt;read_documentation,&lt;br/&gt;get_regional_availability, etc.

    Note over CC,AI: User asks AWS question

    CC-&gt;&gt;AI: Available tools from all MCP servers
    AI-&gt;&gt;AI: Analyze question &amp; match tools
    AI-&gt;&gt;CC: Use aws___search_documentation
    CC-&gt;&gt;MCP: Execute search_documentation("Lambda timeout")
    MCP-&gt;&gt;CC: Return AWS documentation results
    CC-&gt;&gt;AI: Results from AWS Knowledge
    AI-&gt;&gt;CC: Formatted response to user
</code></pre>

<h3 id="tool-descriptions-the-key-to-smart-routing">Tool Descriptions: The Key to Smart Routing</h3>
<p>Each MCP server provides detailed metadata about its tools. This metadata is what allows the AI to make intelligent routing decisions. Let’s look at real examples:</p>

<p><strong>AWS Knowledge MCP Server advertises:</strong></p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"aws___search_documentation"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Search AWS documentation, blogs, What's New announcements, and technical guides for information about AWS services, features, and best practices"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"inputSchema"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"object"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"search_phrase"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"string"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Search query for AWS documentation"</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"limit"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"integer"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Maximum number of results to return"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>A hypothetical Weather MCP Server would advertise:</strong></p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"weather___get_forecast"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Get weather forecast for a specific location including temperature, precipitation, and conditions for upcoming days"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"inputSchema"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"object"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"location"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"string"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"City name or coordinates"</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"days"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"integer"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Number of days to forecast (1-10)"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Notice how different these descriptions are? The AI uses these descriptions to understand <strong>what each tool does</strong> and <strong>when to use it</strong>.</p>

<h3 id="the-decision-making-process">The Decision-Making Process</h3>
<p>When you ask a question, here’s how the AI decides which tool to use:</p>

<pre><code class="language-mermaid">graph TD
    A[User asks:&lt;br/&gt;'What are Lambda timeout limits?'] --&gt; B[AI analyzes question]
    B --&gt; C{Check all available&lt;br/&gt;MCP tools}

    C --&gt; D1[aws___search_documentation&lt;br/&gt;AWS docs, services, features]
    C --&gt; D2[weather___get_forecast&lt;br/&gt;Weather data for locations]
    C --&gt; D3[github___search_code&lt;br/&gt;Search code repositories]

    B --&gt; E{Match question to&lt;br/&gt;tool descriptions}

    E --&gt; F{Keywords:&lt;br/&gt;Lambda, timeout, limits}
    F --&gt; G{Best match?}

    G --&gt;|AWS service| H[✓ aws___search_documentation]
    G --&gt;|Weather related| I[✗ weather___get_forecast]
    G --&gt;|Code related| J[✗ github___search_code]

    H --&gt; K[Execute AWS Knowledge query]
    K --&gt; L[Return Lambda timeout documentation]

    style H fill:#2ECC40
    style I fill:#FF4136
    style J fill:#FF4136
</code></pre>

<h3 id="what-makes-this-work">What Makes This Work</h3>
<p>The AI considers several factors when choosing a tool:</p>

<ol>
  <li><strong>Semantic Matching</strong>: Does the tool description align with the question’s intent?
    <ul>
      <li>“Lambda timeout limits” semantically matches “AWS services, features, and best practices”</li>
      <li>It does NOT match “weather forecast” or “code repositories”</li>
    </ul>
  </li>
  <li><strong>Domain Context</strong>: What domain does the question belong to?
    <ul>
      <li>AWS services → AWS Knowledge MCP Server</li>
      <li>Weather information → Weather MCP Server</li>
      <li>Code search → GitHub MCP Server</li>
    </ul>
  </li>
  <li><strong>Parameter Compatibility</strong>: Can the question be translated into the tool’s required parameters?
    <ul>
      <li>“Lambda timeout limits” → <code class="language-plaintext highlighter-rouge">search_phrase: "Lambda timeout limits"</code></li>
      <li>This fits the AWS Knowledge schema perfectly</li>
    </ul>
  </li>
  <li><strong>Tool Specificity</strong>: More specific tool descriptions get priority
    <ul>
      <li>A tool described as “AWS Lambda documentation” would beat “General cloud documentation”</li>
      <li>Specificity helps avoid ambiguity</li>
    </ul>
  </li>
</ol>

<h3 id="multiple-mcp-servers-working-together">Multiple MCP Servers Working Together</h3>
<p>The beauty of this system is that you can have many MCP servers installed simultaneously, and the AI will route questions to the right one:</p>

<pre><code class="language-mermaid">graph LR
    A[You ask questions] --&gt; B[AI Assistant]

    B --&gt; C{Analyze &amp; Route}

    C --&gt;|AWS questions| D[AWS Knowledge&lt;br/&gt;MCP Server]
    C --&gt;|Weather queries| E[Weather&lt;br/&gt;MCP Server]
    C --&gt;|GitHub searches| F[GitHub&lt;br/&gt;MCP Server]
    C --&gt;|Database queries| G[PostgreSQL&lt;br/&gt;MCP Server]

    D --&gt; H[AWS Documentation]
    E --&gt; I[Weather API]
    F --&gt; J[GitHub API]
    G --&gt; K[Your Database]

    style D fill:#FF851B
    style E fill:#0074D9
    style F fill:#2ECC40
    style G fill:#B10DC9
</code></pre>

<p><strong>Example conversation with multiple servers:</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>You: What's the weather in Sydney?
AI: [Routes to Weather MCP Server] → Returns forecast

You: Does Lambda support ARM64 in ap-southeast-2?
AI: [Routes to AWS Knowledge MCP Server] → Returns AWS docs

You: Show me my S3 buckets
AI: [Routes to AWS API MCP Server] → Lists actual buckets

You: Find TypeScript code that uses Redis
AI: [Routes to GitHub MCP Server] → Searches repositories
</code></pre></div></div>

<p>Each question automatically goes to the right server based on the tool descriptions advertised during the MCP handshake.</p>

<h3 id="writing-good-tool-descriptions">Writing Good Tool Descriptions</h3>
<p>When building your own MCP server, the quality of your tool descriptions directly impacts how effectively the AI can use your tools. Good descriptions should:</p>

<ol>
  <li><strong>Be specific</strong>: “Search AWS Lambda documentation” beats “Search docs”</li>
  <li><strong>Include domain keywords</strong>: Mention the service, technology, or domain</li>
  <li><strong>Explain the purpose</strong>: What problem does this tool solve?</li>
  <li><strong>Define clear parameters</strong>: What inputs does the tool need?</li>
</ol>

<p>This is why AWS Knowledge MCP Server works so well—its tool descriptions are comprehensive, specific, and well-aligned with the questions developers actually ask about AWS.</p>

<h2 id="conclusion">Conclusion</h2>
<p>The AWS Knowledge MCP Server transforms how you access AWS documentation in Claude Code. With instant access to up-to-date AWS information, you can work faster, learn continuously, and make better-informed decisions—all without leaving your development environment.</p>

<p>The MCP standard ensures these integrations are reliable, secure, and maintainable. As AWS continues to add more specialized servers, the ecosystem will only become more powerful.</p>

<p>In this post, we looked into a rather simple MCP server that is hosted remotely. In the upcoming posts, we will delve deeper into custom MCP servers and MCP servers that run on our dev environments.</p>

<h2 id="resources">Resources</h2>
<ul>
  <li><a href="https://awslabs.github.io/mcp/">AWS MCP Servers Documentation</a> - Complete catalog of all AWS MCP servers</li>
  <li><a href="https://docs.claude.com/en/docs/claude-code/mcp">Claude Code MCP Documentation</a> - MCP configuration guide for Claude Code</li>
  <li><a href="https://github.com/awslabs/mcp">AWS MCP GitHub Repository</a> - Source code and examples</li>
</ul>
]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[MCP Explained]]></title>
    <link href="https://volkanpaksoy.com/archive/2026/08/05/MCP-Explained/"/>
    <updated>2026-08-05T13:10:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2026/08/05/MCP-Explained</id>
    <content type="html"><![CDATA[<h2 id="introduction">Introduction</h2>
<p>Artificial intelligence models are powerful, but they exist in a vacuum. They have knowledge up to their training cutoff date and can only work with information fed directly into a conversation. What happens when you need an AI to access real-time data, query your company’s database, or control devices in your home lab? This is where the Model Context Protocol (MCP) comes in.</p>

<p>MCP is an open standard that acts as a bridge between AI models and external systems. Think of it as a universal translator that allows AI assistants to securely request data from APIs, databases, file systems, and other services without requiring custom integrations for each one. In this post, we’ll explore what MCP is, how it works, and why it matters for the future of AI applications.</p>

<h2 id="what-is-the-model-context-protocol">What Is the Model Context Protocol?</h2>
<p>At its core, MCP is a standardized specification for how AI models can communicate with external data sources and tools. Instead of building custom code for each integration, developers can create MCP “servers” that expose their tools, databases, or APIs in a way that any MCP-compatible model can understand and use.</p>

<p>The key innovation is standardization. Without MCP, every time you wanted an AI model to access a new service, you’d need to describe that service’s API in detail within your prompt or system instructions. The model would then have to figure out how to call the right endpoints with the right parameters. It’s fragile, prone to errors, and doesn’t scale well.</p>

<p>MCP solves this by defining a clear protocol. An MCP server advertises what it can do—what tools it offers, what parameters they take, and what they return. The AI model receives this information in a structured format, understands it automatically, and can reliably invoke the right operations.</p>

<pre><code class="language-mermaid">graph LR
    A[AI Model&lt;br/&gt;Assistant] &lt;--&gt; B[MCP Protocol&lt;br/&gt;Standardized Interface]
    B &lt;--&gt; C[MCP Server&lt;br/&gt;Database]
    B &lt;--&gt; D[MCP Server&lt;br/&gt;API]
    B &lt;--&gt; E[MCP Server&lt;br/&gt;File System]
    B &lt;--&gt; F[MCP Server&lt;br/&gt;IoT Devices]

    style A fill:#2ECC40
    style B fill:#FF851B
    style C fill:#0074D9
    style D fill:#0074D9
    style E fill:#0074D9
    style F fill:#0074D9
</code></pre>

<h2 id="how-mcp-works-the-simple-version">How MCP Works: The Simple Version</h2>
<p>Imagine you have a restaurant reservation database and want an AI assistant to help customers check availability. Here’s the flow:</p>

<ol>
  <li>
    <p>You build an MCP server that exposes a tool called “check_availability” with parameters for date, time, and party size.</p>
  </li>
  <li>
    <p>The MCP server defines this in a standard schema that clearly states: “This tool requires a date (string), a time (string), and party_size (number).”</p>
  </li>
  <li>
    <p>When a user asks the AI “Do you have availability for 4 people tomorrow at 7 PM?”, the AI receives the MCP server’s capability list, understands what tools are available, and calls the check_availability tool with the right parameters.</p>
  </li>
  <li>
    <p>The MCP server processes the request, queries your database, and returns the result to the AI.</p>
  </li>
  <li>
    <p>The AI presents the answer to the user: “Yes, we have a table available.”</p>
  </li>
</ol>

<p>This entire flow is standardized. The AI doesn’t need custom code or special prompting—it just follows the protocol.</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant User
    participant AI as AI Assistant
    participant MCP as MCP Server&lt;br/&gt;(Restaurant DB)
    participant DB as Database

    User-&gt;&gt;AI: "Table for 4 tomorrow at 7 PM?"
    AI-&gt;&gt;MCP: Get available tools
    MCP-&gt;&gt;AI: Tools: check_availability(date, time, party_size)
    AI-&gt;&gt;MCP: check_availability("2025-10-28", "19:00", 4)
    MCP-&gt;&gt;DB: Query availability
    DB-&gt;&gt;MCP: Available: Yes
    MCP-&gt;&gt;AI: Result: Table available
    AI-&gt;&gt;User: "Yes, we have a table available."

    Note over AI,MCP: Standardized MCP Protocol
</code></pre>

<h2 id="real-life-usage-scenarios">Real-Life Usage Scenarios</h2>

<p><strong>Scenario 1: Corporate Data Access</strong>
A financial analyst asks their AI assistant, “What was our revenue growth in Q3?” Instead of manually pulling data, the AI calls an MCP server that queries the company’s data warehouse. The result is real, current, and accurate. The analyst gets instant answers without leaving the chat interface.</p>

<p><strong>Scenario 2: Home Automation</strong>
You have smart lights, thermostats, and security cameras in your home lab. An MCP server exposes these as tools. You can ask your AI assistant, “What’s the current temperature?” or “Turn on the lights in the living room,” and the AI reliably interacts with your devices through the MCP server.</p>

<p><strong>Scenario 3: Knowledge Base Integration</strong>
A support team uses MCP to connect their AI assistant to the internal knowledge base. When helping customers, the AI can search company documentation, policies, and FAQs in real-time. It provides accurate, consistent answers based on actual company knowledge, not hallucinations.</p>

<p><strong>Scenario 4: DevOps and Monitoring</strong>
An MCP server exposes system monitoring tools—check CPU usage, disk space, running processes, recent errors. An engineer asks their AI assistant, “What’s wrong with the production server?” The AI queries the monitoring tools, analyzes the data, and provides troubleshooting suggestions.</p>

<pre><code class="language-mermaid">graph LR
    A[AI&lt;br/&gt;Assistant] --&gt; B[MCP Protocol]

    B --&gt; C1[Enterprise:&lt;br/&gt;Data Warehouses,&lt;br/&gt;Knowledge Bases,&lt;br/&gt;CRM Systems]

    B --&gt; C2[Home &amp; Personal:&lt;br/&gt;Smart Devices,&lt;br/&gt;Files,&lt;br/&gt;Media Libraries]

    B --&gt; C3[Development:&lt;br/&gt;Code Repos,&lt;br/&gt;Build Systems,&lt;br/&gt;Package Managers]

    B --&gt; C4[Operations:&lt;br/&gt;Monitoring,&lt;br/&gt;Logs,&lt;br/&gt;Infrastructure]

    style A fill:#2ECC40
    style B fill:#FF851B
    style C1 fill:#0074D9
    style C2 fill:#0074D9
    style C3 fill:#0074D9
    style C4 fill:#0074D9
</code></pre>

<h2 id="why-mcp-matters">Why MCP Matters</h2>
<p>Without MCP, integrating external systems with AI requires constant manual effort. You write custom code, describe it to the model in prompts, and hope the model interprets your instructions correctly. This approach is error-prone and doesn’t scale.</p>

<p>MCP enables a future where tools and data sources are AI-ready by default. Developers expose their systems through MCP, and any AI model that understands the protocol can use them reliably. It’s a win for security too—you define exactly what operations are allowed and how they should be executed, rather than trusting an AI to interpret natural language instructions.</p>

<h2 id="conclusion">Conclusion</h2>
<p>The Model Context Protocol represents a fundamental shift in how AI systems interact with the real world. By standardizing the interface between models and external systems, MCP makes AI more practical, reliable, and powerful. Whether you’re building enterprise applications, home automation systems, or experimental tools, MCP provides a framework for safely and reliably connecting your data and services to AI.</p>

<p>As MCP adoption grows, we can expect to see a thriving ecosystem of servers and integrations. For developers, the takeaway is clear: if you have data or tools you want AI systems to access, MCP is the modern way to do it. For users, it means AI models will become increasingly useful as practical assistants that can access real information and perform real actions—not just generate text based on training data.</p>

<h2 id="resources">Resources</h2>
<ul>
  <li><a href="https://spec.modelcontextprotocol.io/">MCP Specification</a> - Technical specification and protocol documentation</li>
  <li><a href="https://github.com/modelcontextprotocol">MCP GitHub</a> - Reference implementations and examples</li>
</ul>
]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[LLMs Explained]]></title>
    <link href="https://volkanpaksoy.com/archive/2026/08/05/LLMs-Explained/"/>
    <updated>2026-08-05T13:05:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2026/08/05/LLMs-Explained</id>
    <content type="html"><![CDATA[<p>Large Language Models have become central to modern artificial intelligence, powering everything from chatbots to code generation tools. Yet for many, they remain mysterious black boxes. This post breaks down how LLMs work, from their fundamental architecture to why they’re so remarkably capable.</p>

<h2 id="what-is-an-llm">What Is an LLM?</h2>
<p>A Large Language Model is a type of neural network trained to predict the next token (usually a word or subword) in a sequence. The term “large” refers to both the model’s architecture and its training data. Modern LLMs contain billions of parameters—adjustable weights that shape how information flows through the network—and are trained on trillions of tokens of text from diverse internet sources.</p>

<p>The core task sounds deceptively simple: given a sequence of words, predict what comes next. Yet this seemingly elementary objective, applied at massive scale with sophisticated architecture, produces systems capable of reasoning, coding, translation, and creative writing.</p>

<h2 id="the-transformer-architecture">The Transformer Architecture</h2>
<p>The breakthrough that enabled modern LLMs was the introduction of the Transformer architecture in 2017. Unlike earlier approaches like recurrent neural networks (RNNs), Transformers use a mechanism called attention to process sequences of text.</p>

<p>The attention mechanism allows the model to examine relationships between all words in an input simultaneously, rather than processing them sequentially. When the model encounters the word “bank,” attention helps it determine whether this refers to a financial institution or the side of a river based on context from the entire input. This parallel processing dramatically improved both training efficiency and model performance.</p>

<p>Transformers are built from stacked layers of attention and feed-forward neural networks. Each layer refines its understanding of the input, learning to extract increasingly abstract features. Early layers might recognize simple patterns like parts of speech, while deeper layers understand semantic relationships and complex reasoning.</p>

<pre><code class="language-mermaid">graph TB
    A[Input Text: Tokens] --&gt; B[Embedding Layer]
    B --&gt; C[Transformer Layer 1]
    C --&gt; D[Attention Mechanism]
    D --&gt; E[Feed-Forward Network]
    E --&gt; F[Transformer Layer 2]
    F --&gt; G[Attention Mechanism]
    G --&gt; H[Feed-Forward Network]
    H --&gt; I[...]
    I --&gt; J[Final Layer]
    J --&gt; K[Output: Next Token Prediction]

    style D fill:#2ECC40
    style G fill:#2ECC40
    style E fill:#0074D9
    style H fill:#0074D9
</code></pre>

<h2 id="training-from-text-to-intelligence">Training: From Text to Intelligence</h2>

<p>LLM training happens in two main phases: pre-training and fine-tuning.</p>

<p>During pre-training, models are exposed to enormous quantities of text—books, websites, code repositories, scientific papers—and learn to predict the next token. This self-supervised learning requires no manually labeled data; the objective is built into the task itself. Through this process, the model absorbs patterns about language, factual knowledge, reasoning patterns, and how ideas connect.</p>

<p>Pre-training is computationally expensive, requiring specialized hardware like GPUs or TPUs and taking weeks or months. After pre-training, the base model is remarkably capable but still rough around the edges.</p>

<p>Fine-tuning comes next. Here, models are trained on smaller, curated datasets with human feedback. Techniques like Reinforcement Learning from Human Feedback (RLHF) help align model outputs with human preferences. Fine-tuning reduces harmful outputs, improves instruction-following, and makes models more helpful and honest.</p>

<pre><code class="language-mermaid">graph LR
    A[Raw Text Data&lt;br/&gt;Trillions of Tokens] --&gt; B[Pre-training&lt;br/&gt;Next Token Prediction]
    B --&gt; C[Base Model&lt;br/&gt;Raw Capabilities]
    C --&gt; D[Supervised Fine-tuning&lt;br/&gt;Curated Examples]
    D --&gt; E[RLHF&lt;br/&gt;Human Feedback]
    E --&gt; F[Aligned Model&lt;br/&gt;Helpful &amp; Safe]

    style B fill:#FF851B
    style D fill:#0074D9
    style E fill:#2ECC40
</code></pre>

<h2 id="why-theyre-so-capable">Why They’re So Capable</h2>

<p>The capability of modern LLMs emerges from scale, architecture, and training data. Research has shown that performance improves predictably as models grow larger and train on more data—a phenomenon called scaling laws. With enough parameters and training data, these models develop unexpected abilities, sometimes called emergent capabilities.</p>

<p>For instance, LLMs weren’t explicitly programmed to write code, summarize text, or translate languages. Yet with sufficient scale, they spontaneously developed these skills. Few-shot learning is another emergent ability: models can adapt to new tasks with just a few examples, rather than requiring retraining.</p>

<p>This happens because language encodes knowledge about the world. When an LLM learns that “Paris is in France” appears frequently in training data, it internalizes this relationship. Scaling and diverse training data compound this effect, enabling models to handle complex reasoning, creative tasks, and specialized domains.</p>

<pre><code class="language-mermaid">graph TD
    A[Scale: Parameters + Data] --&gt; B[Basic Language Understanding]
    B --&gt; C[Emergent Capabilities]
    C --&gt; D[Code Generation]
    C --&gt; E[Translation]
    C --&gt; F[Few-shot Learning]
    C --&gt; G[Complex Reasoning]
    C --&gt; H[Creative Writing]

    style A fill:#B10DC9
    style C fill:#FF851B
    style D fill:#2ECC40
    style E fill:#2ECC40
    style F fill:#2ECC40
    style G fill:#2ECC40
    style H fill:#2ECC40
</code></pre>

<h2 id="the-limitations">The Limitations</h2>

<p>Understanding what LLMs cannot do is equally important. Despite their sophistication, they have fundamental limitations:</p>

<p>LLMs are pattern-matching systems, not reasoning engines. They can produce plausible-sounding text that is factually incorrect—a phenomenon called hallucination. They cannot access real-time information or maintain true long-term memory across conversations. Their outputs reflect biases present in training data. They sometimes struggle with novel problems that don’t match learned patterns.</p>

<p>Additionally, LLMs have finite context windows—maximum amounts of text they can process at once. This limits their ability to handle very long documents or maintain extended conversations.</p>

<h2 id="looking-forward">Looking Forward</h2>

<p>LLMs represent a significant step forward in AI, but they’re not the final answer. Researchers are exploring hybrid approaches combining LLMs with retrieval systems, symbolic reasoning, and other techniques to address current limitations. The field continues to evolve rapidly, with improvements in efficiency, alignment, and capability.</p>

<p>Understanding how LLMs work—their strengths and limitations—is essential for anyone working with or relying on modern AI systems. They’re powerful tools, not oracles, and using them effectively requires realistic expectations about their nature and capabilities.</p>
]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[Respond to Twilio Webhooks using AWS Lambda and .NET]]></title>
    <link href="https://volkanpaksoy.com/archive/2026/08/05/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/"/>
    <updated>2026-08-05T13:00:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2026/08/05/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET</id>
    <content type="html"><![CDATA[<blockquote>
  <p>This article was originally published on the <a href="https://www.twilio.com/en-us/blog/respond-to-twilio-webhooks-using-aws-lambda-and-dotnet">Twilio Blog</a>.</p>
</blockquote>

<p>In this article, you will learn how to develop a web API with .NET 6 to handle Twilio webhooks and deploy it to <a href="https://aws.amazon.com/lambda/">AWS Lambda</a>. You will also learn how to save call recordings to <a href="https://aws.amazon.com/pm/serv-s3/?trk=fecf68c9-3874-4ae2-a7ed-72b6d19c8034&amp;sc_channel=ps&amp;sc_campaign=acquisition&amp;sc_medium=ACQ-P%7CPS-GO%7CBrand%7CDesktop%7CSU%7CStorage%7CS3%7CUS%7CEN%7CText&amp;s_kwcid=AL!4422!3!536452728638!e!!g!!aws%20s3&amp;ef_id=CjwKCAjw6MKXBhA5EiwANWLODLnrzGqXlqN_QSJeviU_n3O6QLm1DlCOccHhko4_AkQE4nTChVhk9hoCHGEQAvD_BwE:G:s&amp;s_kwcid=AL!4422!3!536452728638!e!!g!!aws%20s3">AWS S3</a> as MP3 files.</p>

<h2 id="prerequisites">Prerequisites</h2>

<ul>
  <li>
    <p>A free Twilio account (<a href="https://www.twilio.com/referral/ZOvl3g">sign up with Twilio using this link</a> and get $10 in free credit when you upgrade your account)</p>
  </li>
  <li>
    <p>A <a href="https://support.twilio.com/hc/en-us/articles/223135247-How-to-Search-for-and-Buy-a-Twilio-Phone-Number-from-Console">Twilio Phone Number</a></p>
  </li>
  <li>
    <p>An <a href="https://portal.aws.amazon.com/billing/signup">AWS account</a></p>
  </li>
  <li>
    <p>An OS that supports .NET (Windows/macOS/Linux)</p>
  </li>
  <li>
    <p><a href="https://dotnet.microsoft.com/en-us/download/dotnet/6.0">.NET 6.0 SDK (newer and older versions may work too)</a></p>
  </li>
  <li>
    <p>A code editor or IDE (Recommended: <a href="https://code.visualstudio.com/Download">Visual Studio Code</a> with <a href="https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp">the C# plugin</a>, <a href="https://visualstudio.microsoft.com/">Visual Studio</a>, or <a href="https://www.jetbrains.com/rider/">JetBrains Rider</a>)</p>
  </li>
  <li>
    <p><a href="https://aws.amazon.com/cli/">AWS CLI</a></p>
  </li>
</ul>

<h2 id="what-are-webhooks">What are webhooks?</h2>

<p>In today’s API-driven world, integrating applications is easier than ever. Most of the time, you can get the information you need from an external system’s API, but sometimes you want to be notified by the external system when something happens. That’s where webhooks come in. You register your own endpoint with the external system, and they post data to your endpoint when the event you’re looking for occurs.</p>

<h2 id="twilio-webhooks">Twilio Webhooks</h2>

<p>The type of data you can expect from webhooks depends on the Twilio service. For the Twilio Voice API, there are <a href="https://www.twilio.com/docs/usage/webhooks/voice-webhooks">several types of webhooks</a>, three of which you’ll use in this tutorial:</p>

<ul>
  <li>
    <p>Incoming voice call</p>
  </li>
  <li>
    <p>Status callback</p>
  </li>
  <li>
    <p>Recording status callback</p>
  </li>
</ul>

<p>Incoming voice call webhook, as the name implies, is where you handle the incoming calls. When you use a programmable voice service such as Twilio, this is the core functionality you would want to implement. If you don’t handle the incoming call, you hear three beeps when you call your Twilio number, and the call is terminated. The call doesn’t even appear in the logs. As you will see later in this article, when you implement a call handler, you can provide instructions to Twilio to record the call, play audio, and more. Some of these actions also have their own follow up webhooks. These instructions are implemented in <a href="https://www.twilio.com/docs/voice/twiml">TwiML (the Twilio Markup Language)</a>. TwiML is an XML-based markup language that has elements such as <a href="https://www.twilio.com/docs/voice/twiml/say">Say</a> (read text to the caller), <a href="https://www.twilio.com/docs/voice/twiml/dial">Dial</a> (add another party to the call) and <a href="https://www.twilio.com/docs/voice/twiml/record">Record</a> (record the caller’s voice). You will use Say and Record in your project later.</p>

<p>After a call has been completed (inbound or outbound), Twilio sends an HTTP request to your endpoint. This is called the status callback.</p>

<p>You could receive the recording status callback if you requested to record the call. Then, Twilio sends your endpoint a message with the recording status and a URL to access the recording file. You have to specify your webhook URL to handle this callback message.</p>

<p>!!!warning</p>

<p>By default, Recording URLs don’t require authentication, and recordings are not encrypted. However, you can require basic authentication to access the recordings and <a href="https://www.twilio.com/docs/voice/tutorials/voice-recording-encryption">configure recordings to be encrypted</a> in the voice settings (Voice → Settings → General).</p>

<p>!!!</p>

<p>In this tutorial you will interact with the Twilio Voice product, but many other products also use webhooks and you can apply the same technique for them as you will for Voice.</p>

<p>Now that you’ve learned about these three webhooks, let’s move on to the next section.</p>

<h2 id="set-up-aws-iam-user">Set up AWS IAM User</h2>

<p>You will need credentials to deploy your application to AWS from the command line. To create the credentials, follow the steps below:</p>

<p>First, go to the <a href="https://us-east-1.console.aws.amazon.com/iamv2/home#/users">AWS IAM Users Dashboard</a> and click the Add users button.</p>

<p>Enter the user name such as twilio-webhook-user and tick the Access key - Programmatic access checkbox:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/01.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 1" /></p>

<p>Click the Next: Permissions button at the bottom right.</p>

<p>Then, select Attach existing policies directly and select AdministratorAccess:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/02.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 2" /></p>

<p>Click the Next: Tags button at the bottom right. Tags are optional (and quite valuable information), and it’s a good practice to add descriptive tags to the resources you create. Since this is a demo project, you can skip this step and click the Next: Review button at the bottom.</p>

<p>Confirm your selection on the review page. It should look like this:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/03.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 3" /></p>

<p>Then, click the Create user button.</p>

<p>In the final step of the user creation process, you should see your credentials for the first and the last time.</p>

<p>!!!warning</p>

<p>Take note of your Access key ID and Secret access key before you press the close button.</p>

<p>!!!</p>

<p>Now, open a terminal window and run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws configure
</code></pre></div></div>

<p>You should see a prompt for AWS Access Key ID. Copy and paste your access key ID and press enter.</p>

<p>Then, copy and paste your secret access key and press enter.</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/04.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 4" /></p>

<p>When prompted, type us-east-1 as the default region name and press enter.</p>

<p>!!!info</p>

<p>In this example, I will use the us-east-1 region. Regions are geographical locations where AWS have their data centers. It is a good practice to deploy as close to your customers as possible for production deployments to reduce latency. Since this is a demo project, you can use us-east-1 for convenience as it’s the default region in AWS Management Console. You can find more on AWS regions in this document: <a href="https://aws.amazon.com/about-aws/global-infrastructure/regions_az/">Regions and Availability Zones</a>.</p>

<p>!!!</p>

<p>As the default output format, type json and press enter.</p>

<p>To confirm you have configured your AWS profile correctly, run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws configure list
</code></pre></div></div>

<p>The output should look like this:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/05.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 5" /></p>

<p>Now that you have set up your AWS credentials, you can move on to setting up the code.</p>

<h2 id="create-an-aspnet-core-project-for-aws-lambda">Create an ASP.NET Core project for AWS Lambda</h2>

<p>You can download the finished project from <a href="https://github.com/cloudinternals/respond-to-twilio-webhooks-using-aws-lambda-and-dotnet">GitHub</a>. However, this article will provide step-by-step instructions to set it up yourself.</p>

<p>Open a terminal and navigate to the directory that will be the root of your project.</p>

<p>You will use the Lambda ASP.NET Core Web API project template in the sample project. So, first, install Lambda templates by running the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet new <span class="nt">-i</span> Amazon.Lambda.Templates
</code></pre></div></div>

<p>You should see the results of a successful installation:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/06.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 6" /></p>

<p>Take note of the short name of Lambda ASP.NET Core Web API: serverless.AspNetCoreWebAPI.</p>

<p>Then, run the following command to create the project:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet new serverless.AspNetCoreWebAPI <span class="nt">--name</span> TwilioWebhookLambda.WebApi <span class="nt">--output</span> <span class="nb">.</span>
</code></pre></div></div>

<p>The command above will create a new project with the following file structure:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/07.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 7" /></p>

<p>Note that the template creates a folder named src and puts the project in that folder. You can move the code to your root folder, but the rest of the article will use the default paths.</p>

<p>You will leverage a new AWS Lambda feature called <a href="https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html">Function URLs</a> to make the function publicly available. For this to work with your API you need to install the <a href="https://www.nuget.org/packages/Amazon.Lambda.AspNetCoreServer.Hosting">Amazon.Lambda.AspNetCoreServer.Hosting NuGet package</a>. In the terminal window, navigate to the project folder and run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd </span>src/TwilioWebhookLambda.WebApi
dotnet add package Amazon.Lambda.AspNetCoreServer.Hosting
</code></pre></div></div>

<p>Then, open Startup.cs in your IDE, update the <code class="language-plaintext highlighter-rouge">ConfigureServices</code> method so that it looks like this:</p>

<p>```csharp hl_lines=”4”
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddAWSLambdaHosting(LambdaEventSource.HttpApi);
}</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
Lambda Function URLs use HttpApi behind the scenes, so you need to use `LambdaEventSource.HttpApi` as the event source type.

You will need the Amazon Lambda Tools .NET tool to deploy the function via the command line. You can install it by running the command below:

```bash
dotnet tool install -g Amazon.Lambda.Tools
</code></pre></div></div>

<p>Amazon Lambda Tools, use the aws-lambda-tools-defaults.json file to get some details about the installation. Unfortunately, it doesn’t come with all the values it needs. For example, you can store the runtime and the function’s name in this file, so you don’t have to keep entering it whenever you deploy it from scratch.</p>

<p>Open the file and update it so that it looks like this:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"profile"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
  </span><span class="nl">"region"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
  </span><span class="nl">"configuration"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Release"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"function-runtime"</span><span class="p">:</span><span class="w"> </span><span class="s2">"dotnet6"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"function-memory-size"</span><span class="p">:</span><span class="w"> </span><span class="mi">256</span><span class="p">,</span><span class="w">
  </span><span class="nl">"function-timeout"</span><span class="p">:</span><span class="w"> </span><span class="mi">30</span><span class="p">,</span><span class="w">
  </span><span class="nl">"function-handler"</span><span class="p">:</span><span class="w"> </span><span class="s2">"TwilioWebhookLambda.WebApi"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"function-name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"TwilioWebhookLambda-WebApi"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"function-url-enable"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>If you don’t provide profile and region values, it uses the default profile and region in your AWS configuration. If you want to override the defaults, update those values as well.</p>

<p>Then, deploy the Lambda function by running the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet lambda deploy-function
</code></pre></div></div>

<p>Your Lambda function needs an IAM role to execute. The policies attached to this role determine the permissions of the function. By default, the Amazon Lambda Tools create a role on your behalf and attach it to the function.</p>

<p>During the deployment, it lists the existing roles along with an option to create a new role:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/08.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 8" /></p>

<p>Select the Create new IAM Role option.</p>

<p>Give it a descriptive name, such as TwilioWebhookLambda-WebApi-Role, so that you can easily determine its purpose when you see it in your IAM dashboard.</p>

<p>The next step is to select the IAM policy. Your project will need Amazon S3 access to store call recordings. Also, having access to CloudWatch logs is always helpful. So choose 3 - AWSLambdaExecute from the list:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/09.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 9" /></p>

<p>!!!info</p>

<p>As a best practice, you should develop custom policies to grant only the minimum required permissions.</p>

<p>!!!</p>

<p>After the deployment has finished, you should see the successful deployment message:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/10.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 10" /></p>

<p>The publicly available URL shown above is only created because you enabled the Function URL feature in the aws-lambda-tools-defaults.json file.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"function-url-enable"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></code></pre></div></div>

<p>Without this feature, you wouldn’t be able to use a Lambda function as a webhook handler.</p>

<p>Now open that URL in a browser and you should see the default GET / endpoint result:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/11.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 11" /></p>

<p>The API works like any other API. This template comes with a sample controller called ValuesController. Test the controller by appending /api/values to your function URL:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/12.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 12" /></p>

<p>You should see an array of strings (value1 and value2) displayed on your browser.</p>

<p>You just deployed your ASP.NET Core web API to Lambda and made it publicly available. Great job!</p>

<h3 id="receive-incoming-calls">Receive Incoming Calls</h3>

<p>The <a href="https://www.twilio.com/docs/libraries/csharp-dotnet">Twilio .NET SDK</a> and the <a href="https://github.com/twilio-labs/twilio-aspnet">helper library for ASP.NET</a> make it easier to build Twilio applications. In this tutorial, you’ll use the SDK to generate TwiML and the helper library to respond to webhook requests. Add the SDK and helper library via NuGet:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package Twilio
dotnet add package Twilio.AspNet.Core
</code></pre></div></div>

<p>Under the Controllers folder, add a new file called IncomingCallController.cs and replace its contents with the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Mvc</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.AspNet.Core</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.TwiML</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">TwilioWebhookLambda.WebApi.Controllers</span><span class="p">;</span>
<span class="p">[</span><span class="n">ApiController</span><span class="p">]</span>
<span class="p">[</span><span class="nf">Route</span><span class="p">(</span><span class="s">"api/[controller]"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">IncomingCallController</span> <span class="p">:</span> <span class="n">TwilioController</span>
<span class="p">{</span>
    <span class="p">[</span><span class="n">HttpPost</span><span class="p">]</span>
    <span class="k">public</span> <span class="n">TwiMLResult</span> <span class="nf">Index</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">VoiceResponse</span><span class="p">();</span>
        <span class="n">response</span><span class="p">.</span><span class="nf">Say</span><span class="p">(</span><span class="s">"Hello. Please leave a message after the beep."</span><span class="p">);</span>
        <span class="k">return</span> <span class="nf">TwiML</span><span class="p">(</span><span class="n">response</span><span class="p">);</span>    
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>In the terminal, deploy the updated function:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet lambda deploy-function
</code></pre></div></div>

<p>You should get a successful update message on your screen:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/13.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 13" /></p>

<p>At this point, you have a publicly available endpoint but Twilio is not aware of it yet.</p>

<p>Go to the <a href="https://www.twilio.com/console">Twilio console</a>. Select your account, and then click Phone Numbers → Manage → Active Numbers on the left pane. (If Phone Numbers isn’t on the left pane, click on Explore Products and then on Phone Numbers.)</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/14.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 14" /></p>

<p>!!!warning</p>

<p>You don’t permanently own Twilio numbers; instead, you lease them until you release them. If you release a number after a 10-day grace period, it is returned to the number pool.</p>

<p>!!!</p>

<p>Click on the phone number you want to use for your project and scroll down to the Voice section.</p>

<p>Under the “A Call Comes In” label, set the dropdown to Webhook, the text field next to it to your Lambda Function URL suffixed with the /IncomingCall path, the next dropdown to HTTP POST, and click Save. It should look like this:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/15.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 15" /></p>

<p>To test, call your Twilio number, and you should hear the message “Hello. Please leave a message after the beep.”. It doesn’t actually wait for the message, but at least you know you have implemented an incoming voice webhook. Your code is executed when your Twilio number receives a call.</p>

<p>In the next section, you will handle the second webhook type: Call Status Updates.</p>

<h3 id="receive-call-status-updates">Receive Call Status Updates</h3>

<p>Create a new file in the Controllers folder called CallStatusChangeController.cs with the code below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Mvc</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.AspNet.Core</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">TwilioWebhookLambda.WebApi.Controllers</span><span class="p">;</span>
<span class="p">[</span><span class="n">ApiController</span><span class="p">]</span>
<span class="p">[</span><span class="nf">Route</span><span class="p">(</span><span class="s">"api/[controller]"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">CallStatusChangeController</span> <span class="p">:</span> <span class="n">TwilioController</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">ILogger</span><span class="p">&lt;</span><span class="n">CallStatusChangeController</span><span class="p">&gt;</span> <span class="n">_logger</span><span class="p">;</span>
    <span class="k">public</span> <span class="nf">CallStatusChangeController</span><span class="p">(</span><span class="n">ILogger</span><span class="p">&lt;</span><span class="n">CallStatusChangeController</span><span class="p">&gt;</span> <span class="n">logger</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_logger</span> <span class="p">=</span> <span class="n">logger</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="p">[</span><span class="n">HttpPost</span><span class="p">]</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">Index</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">form</span> <span class="p">=</span> <span class="k">await</span> <span class="n">Request</span><span class="p">.</span><span class="nf">ReadFormAsync</span><span class="p">();</span>
        <span class="kt">var</span> <span class="n">to</span> <span class="p">=</span> <span class="n">form</span><span class="p">[</span><span class="s">"To"</span><span class="p">];</span>
        <span class="kt">var</span> <span class="n">callStatus</span> <span class="p">=</span> <span class="n">form</span><span class="p">[</span><span class="s">"CallStatus"</span><span class="p">];</span>
        <span class="kt">var</span> <span class="n">fromCountry</span> <span class="p">=</span> <span class="n">form</span><span class="p">[</span><span class="s">"FromCountry"</span><span class="p">];</span>
        <span class="kt">var</span> <span class="n">duration</span> <span class="p">=</span> <span class="n">form</span><span class="p">[</span><span class="s">"Duration"</span><span class="p">];</span>
        <span class="n">_logger</span><span class="p">.</span><span class="nf">LogInformation</span><span class="p">(</span>
            <span class="s">"Message to {to} changed to {callStatus}. (from country: {fromCountry}, duration: {duration})"</span><span class="p">,</span>
            <span class="n">to</span><span class="p">,</span> <span class="n">callStatus</span><span class="p">,</span> <span class="n">fromCountry</span><span class="p">,</span> <span class="n">duration</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This code logs some of the values posted to your webhook.</p>

<p>Go back to the active number configuration in the Twilio Console and update the “Call Status Changes” field with your Lambda Function URL suffixed with /CallStatusChange, as shown below:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/16.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 16" /></p>

<p>Save your configuration, and then deploy your project again using <code class="language-plaintext highlighter-rouge">dotnet lambda deploy-function</code>.</p>

<p>Now call your Twilio number again, and after the call has been completed, you should see the callback logs in <a href="https://console.aws.amazon.com/cloudwatch">CloudWatch</a>:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/17.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 17" /></p>

<p>You received this message when the call status changed to “completed”.</p>

<p>You can also use the Twilio console to view all call logs: Click Monitor → Calls on the left pane.</p>

<p>Locate the call in the list and click the Call SID link to view the details.</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/18.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 18" /></p>

<p>In the Request Inspector section, you can see all the callbacks with their requests and responses in detail:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/19.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 19" /></p>

<p>Next, you will look into the third and final type of voice webhook: Recording Status Updates.</p>

<h3 id="receive-recording-status-updates">Receive Recording Status Updates</h3>

<p>!!!warning</p>

<p>Before you record anything, please make sure to read this article: <a href="https://support.twilio.com/hc/en-us/articles/360011522553-Legal-Considerations-with-Recording-Voice-and-Video-Communications#:~:text=Getting%20Consent%3A%20Twilio%20requires%20its,participants%20before%20recording%20a%20call.">Legal Considerations with Recording Voice and Video Communications</a>.</p>

<p>!!!</p>

<p>Create a new controller called <code class="language-plaintext highlighter-rouge">RecordingStatusChangeController</code> and replace its contents with the code below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Mvc</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.AspNet.Core</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">TwilioWebhookLambda.WebApi.Controllers</span><span class="p">;</span>
<span class="p">[</span><span class="n">ApiController</span><span class="p">]</span>
<span class="p">[</span><span class="nf">Route</span><span class="p">(</span><span class="s">"api/[controller]"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">RecordingStatusChangeController</span> <span class="p">:</span> <span class="n">TwilioController</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">ILogger</span><span class="p">&lt;</span><span class="n">RecordingStatusChangeController</span><span class="p">&gt;</span> <span class="n">_logger</span><span class="p">;</span>
    <span class="k">public</span> <span class="nf">RecordingStatusChangeController</span><span class="p">(</span><span class="n">ILogger</span><span class="p">&lt;</span><span class="n">RecordingStatusChangeController</span><span class="p">&gt;</span> <span class="n">logger</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_logger</span> <span class="p">=</span> <span class="n">logger</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="p">[</span><span class="n">HttpPost</span><span class="p">]</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">Index</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">form</span> <span class="p">=</span> <span class="k">await</span> <span class="n">Request</span><span class="p">.</span><span class="nf">ReadFormAsync</span><span class="p">();</span>
        <span class="kt">var</span> <span class="n">callSid</span> <span class="p">=</span> <span class="n">form</span><span class="p">[</span><span class="s">"CallSid"</span><span class="p">];</span>
        <span class="kt">var</span> <span class="n">recordingStatus</span> <span class="p">=</span> <span class="n">form</span><span class="p">[</span><span class="s">"RecordingStatus"</span><span class="p">];</span>
        <span class="kt">var</span> <span class="n">recordingUrl</span> <span class="p">=</span> <span class="n">form</span><span class="p">[</span><span class="s">"RecordingUrl"</span><span class="p">];</span>
        <span class="n">_logger</span><span class="p">.</span><span class="nf">LogInformation</span><span class="p">(</span>
            <span class="s">"Recording status changed to {recordingStatus} for call {callSid}. Recording is available at {recordingUrl}"</span>
            <span class="p">,</span><span class="n">recordingStatus</span><span class="p">,</span> <span class="n">callSid</span><span class="p">,</span> <span class="n">recordingUrl</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Similar to the status change handler, this code only logs some request details. Once you’ve seen all webhooks are working fine, you will update the implementation with more meaningful code.</p>

<p>You also need to modify the <code class="language-plaintext highlighter-rouge">IncomingCallController</code> and replace the code in the <code class="language-plaintext highlighter-rouge">Index</code> method as below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">VoiceResponse</span><span class="p">();</span>
<span class="n">response</span><span class="p">.</span><span class="nf">Say</span><span class="p">(</span><span class="s">"Hello. Please leave a message after the beep."</span><span class="p">);</span>
<span class="n">response</span><span class="p">.</span><span class="nf">Record</span><span class="p">(</span>
    <span class="n">timeout</span><span class="p">:</span> <span class="m">10</span><span class="p">,</span> 
    <span class="n">recordingStatusCallback</span><span class="p">:</span> <span class="k">new</span> <span class="nf">Uri</span><span class="p">(</span><span class="s">"/api/RecordingStatusChange"</span><span class="p">,</span> <span class="n">UriKind</span><span class="p">.</span><span class="n">Relative</span><span class="p">)</span>
<span class="p">);</span>
<span class="k">return</span> <span class="nf">TwiML</span><span class="p">(</span><span class="n">response</span><span class="p">);</span>
</code></pre></div></div>

<p>Now you’re telling Twilio that you’d like to record the phone call. You are also specifying the webhook URL that will receive the recording status update. Unlike the other webhook types, there is no field in the Twilio console to set the recording status callback.</p>

<p>Deploy this update and call your number again. This time you should be able to leave a message after the beep. Once you’ve done that, check your CloudWatch logs, and you should see two status updates: One for the call status and one for the recording status:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/20.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 20" /></p>

<p>As you can see in the logs, the recording URLs are public by default, but the recordings have long random names, so they cannot be iterated through and downloaded by unauthorized parties. To increase the security of the recordings, you can enable Enforce HTTP Auth on Media URLs and Voice Recording Encryption options in <a href="https://console.twilio.com/us1/develop/voice/settings/general?frameUrl=%2Fconsole%2Fvoice%2Fsettings">Voice Settings</a> in your account.</p>

<h2 id="save-recording-mp3-files-to-an-amazon-s3-bucket">Save Recording MP3 files to an Amazon S3 Bucket</h2>

<p>Now let’s see how you can retrieve the recording file and upload it to an Amazon S3 bucket from your ASP.NET Core project.</p>

<p>!!!info</p>

<p>As of May 2022, Twilio has a <a href="https://www.twilio.com/blog/announcing-external-aws-s3-storage-support-for-voice-recordings">built-in feature</a> to store recordings in an Amazon S3 bucket. In this article, however, you will use a different approach and upload the MP3 files programmatically from your Lambda function.</p>

<p>!!!</p>

<p>First, you will need an S3 bucket to store the files. To create the bucket, go to <a href="https://console.aws.amazon.com/">AWS Management Console</a> and search for S3:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/21.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 21" /></p>

<p>Then, click the link to go to the S3 service dashboard.</p>

<p>Click Create Bucket button:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/22.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 22" /></p>

<p>Give it a descriptive and globally unique name, accept all the defaults and click the Create Bucket button at the bottom of the screen.</p>

<p>You should see your bucket in the bucket list:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/23.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 23" /></p>

<p>!!!info</p>

<p>Amazon S3 bucket names are global. If somebody else created a bucket named my-twilio-call-recordings, you can not also use that name. You can find more <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html">bucket naming rules in AWS Documentation</a>.</p>

<p>!!!</p>

<p>In your application, you need to install the AWS SDK packages to talk to the Amazon S3 API.</p>

<p>In the terminal, run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package AWSSDK.S3 
</code></pre></div></div>

<p>Update the <code class="language-plaintext highlighter-rouge">RecordingStatusChangeController</code> code as below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Amazon.S3</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Amazon.S3.Transfer</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Mvc</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.AspNet.Core</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">TwilioWebhookLambda.WebApi.Controllers</span><span class="p">;</span>
<span class="p">[</span><span class="n">ApiController</span><span class="p">]</span>
<span class="p">[</span><span class="nf">Route</span><span class="p">(</span><span class="s">"api/[controller]"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">RecordingStatusChangeController</span> <span class="p">:</span> <span class="n">TwilioController</span>
<span class="p">{</span>
    <span class="p">[</span><span class="n">HttpPost</span><span class="p">]</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">Index</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="kt">string</span> <span class="n">recordingUrl</span> <span class="p">=</span> <span class="n">Request</span><span class="p">.</span><span class="n">Form</span><span class="p">[</span><span class="s">"RecordingUrl"</span><span class="p">];</span>
        <span class="kt">string</span> <span class="n">fileName</span> <span class="p">=</span> <span class="s">$"</span><span class="p">{</span><span class="n">recordingUrl</span><span class="p">.</span><span class="nf">Substring</span><span class="p">(</span><span class="n">recordingUrl</span><span class="p">.</span><span class="nf">LastIndexOf</span><span class="p">(</span><span class="s">"/"</span><span class="p">)</span> <span class="p">+</span> <span class="m">1</span><span class="p">)}</span><span class="s">.mp3"</span><span class="p">;</span>
        <span class="kt">string</span> <span class="n">bucketName</span> <span class="p">=</span> <span class="s">"my-twilio-call-recordings"</span><span class="p">;</span>
        <span class="k">using</span> <span class="nn">HttpClient</span> <span class="n">client</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">HttpClient</span><span class="p">();</span> <span class="c1">// use HttpClient factory in production</span>
        <span class="k">using</span> <span class="nn">HttpResponseMessage</span> <span class="n">response</span> <span class="p">=</span> <span class="k">await</span> <span class="n">client</span><span class="p">.</span><span class="nf">GetAsync</span><span class="p">(</span><span class="s">$"</span><span class="p">{</span><span class="n">recordingUrl</span><span class="p">}</span><span class="s">.mp3"</span><span class="p">);</span>
        <span class="k">using</span> <span class="nn">Stream</span> <span class="n">recordingFileStream</span> <span class="p">=</span> <span class="k">await</span> <span class="n">response</span><span class="p">.</span><span class="n">Content</span><span class="p">.</span><span class="nf">ReadAsStreamAsync</span><span class="p">();</span>
        <span class="k">using</span> <span class="nn">var</span> <span class="n">s3Client</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">AmazonS3Client</span><span class="p">();</span>
        <span class="k">using</span> <span class="nn">var</span> <span class="n">transferUtility</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">TransferUtility</span><span class="p">(</span><span class="n">s3Client</span><span class="p">);</span>
        <span class="k">await</span> <span class="n">transferUtility</span><span class="p">.</span><span class="nf">UploadAsync</span><span class="p">(</span><span class="n">recordingFileStream</span><span class="p">,</span> <span class="n">bucketName</span><span class="p">,</span> <span class="n">fileName</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Make sure to set the <code class="language-plaintext highlighter-rouge">bucketName</code> with your bucket’s name.</p>

<p>In this code, when you receive the recording URL, you extract the recording file name, retrieve the file content via a stream and pass the stream to S3 TransferUtility which uploads it to the Amazon S3 bucket as {fileName}.mp3.</p>

<p>!!!info</p>

<p>By default, the recording URL doesn’t have a file extension. If you call the URL as is, Twilio returns the WAV version of the recording. To get the MP3 version, you need to append .mp3 to the URL as shown in <code class="language-plaintext highlighter-rouge">client.GetAsync</code> call.</p>

<p>!!!</p>

<p>During the setup process, you didn’t explicitly tell AWS that your Lambda function should have access to your S3 bucket. So you might be wondering how you have permission to do that. The reason is you chose AWSLambdaExecute policy to be attached to your function’s role. So if you go to the <a href="https://console.aws.amazon.com/iam/home">IAM dashboard</a> and search AWSLambdaExecute, you should see the policy’s permissions are defined like this:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/24.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 24" /></p>

<p>You can see that this policy has permission to put objects into all S3 buckets. Since this is a demo project, I decided to keep things simple. However, in production, I’d recommend writing your own policy and giving the minimum required permissions, such as using the names of the resources instead of using wildcards. You can read more on that here: <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege">IAM Best Practices: Apply least-privilege permissions</a></p>

<p>Deploy the final version of the API and call your number again.</p>

<p>A short while after you’ve completed the call, you should see the recording in your bucket:</p>

<p><img src="/images/vpblogimg/2026/08/Respond-to-Twilio-Webhooks-using-AWS-Lambda-and-dotNET/25.png" alt="Respond to Twilio Webhooks using AWS Lambda and .NET - image 25" /></p>

<p>!!!warning</p>

<p>As the focus of this article is using AWS Lambda to respond to Twilio webhooks, securing your endpoint is not covered in this article. To learn more about Webhooks Security, you can read this article: <a href="https://www.twilio.com/docs/usage/webhooks/webhooks-security">Webhooks Security</a>.</p>

<p>!!!</p>

<h2 id="conclusion">Conclusion</h2>

<p>Congratulations! You covered three types of webhooks for the Twilio Voice service and implemented handlers for all of them. In addition, you managed to download recordings to your own storage. Later on, you can download or move the files to cold storage using Amazon S3 Glacier. The possibilities are endless when you can manage all of this programmatically. For example, you could use Amazon Transcribe to transcribe the call recording to text, or you could use <a href="https://www.twilio.com/docs/voice/twiml/record#transcribe">Twilio’s transcribe attribute on the record-verb</a>.</p>

<p>If you didn’t follow along and implement the project, don’t worry. You can always download the final project from my <a href="https://github.com/cloudinternals/respond-to-twilio-webhooks-using-aws-lambda-and-dotnet">GitHub repository</a> and experiment on your own.</p>

<p>If you enjoyed playing with call recordings and webhooks using Twilio API, I’d recommend you take a look at these articles as well:</p>

<ul>
  <li>
    <p><a href="https://www.twilio.com/blog/sms-voice-dotnet-6-minimal-api">How to use Twilio SMS and Voice with a .NET 6 Minimal API</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/organize-email-attachments-with-csharp-aspnetcore-twilio-sendgrid-inbound-parse">Organize Incoming Email Attachments with C# and ASP.NET Core using Twilio SendGrid Inbound Parse Webhook</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/use-visual-studio-port-tunneling-with-twilio-webhooks">Use Visual Studio Port Tunneling to handle Twilio Webhooks</a></p>
  </li>
</ul>
]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[Make a Spooky Phone Call using Twilio Voice and Amazon Polly]]></title>
    <link href="https://volkanpaksoy.com/archive/2026/08/05/Make-a-Spooky-Phone-Call-using-Twilio-Voice-and-Amazon-Polly/"/>
    <updated>2026-08-05T12:55:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2026/08/05/Make-a-Spooky-Phone-Call-using-Twilio-Voice-and-Amazon-Polly</id>
    <content type="html"><![CDATA[<blockquote>
  <p>This article was originally published on the <a href="https://www.twilio.com/en-us/blog/developers/community/spooky-phone-call-using-twilio-voice-and-amazon-polly">Twilio Blog</a>.</p>
</blockquote>

<p>Technology provides us with countless benefits, and as programmers, we should better ourselves to produce more value all the time. But then again, there are certain seasons in a year when you should just relax and have fun with your skills. In this tutorial, you will use <a href="https://aws.amazon.com/polly/">Amazon Polly</a> to create audio files from text, add some optional (ideally spooky) sound effects and play that file to your friends using Twilio Voice and .NET.</p>

<p>!!!warning</p>

<p>Needless to say, all this is meant to be is just some harmless fun between you and your loved ones. Do NOT use it if you’re not sure it will be well-received by the person you call. In a production scenario, make your users opt-in before sending them text messages or automated phone calls.</p>

<p>!!!</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>You’ll need the following things in this tutorial:</p>

<ul>
  <li>
    <p>A free <a href="https://www.twilio.com/try-twilio">Twilio account</a></p>
  </li>
  <li>
    <p>A <a href="https://support.twilio.com/hc/en-us/articles/223135247-How-to-Search-for-and-Buy-a-Twilio-Phone-Number-from-Console">Twilio Phone Number</a></p>
  </li>
  <li>
    <p>An OS that supports .NET (Windows/macOS/Linux)</p>
  </li>
  <li>
    <p><a href="https://dotnet.microsoft.com/en-us/download/dotnet/6.0">.NET 6.0 SDK</a> (newer and older versions may work too)</p>
  </li>
  <li>
    <p>A code editor or IDE (Recommended: <a href="https://code.visualstudio.com/Download">Visual Studio Code</a> with <a href="https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp">the C# plugin</a>, <a href="https://visualstudio.microsoft.com/">Visual Studio</a>, or <a href="https://www.jetbrains.com/rider/">JetBrains Rider</a>)</p>
  </li>
  <li>
    <p>An <a href="https://portal.aws.amazon.com/billing/signup">AWS account</a></p>
  </li>
  <li>
    <p><a href="https://aws.amazon.com/cli/">AWS CLI</a></p>
  </li>
  <li>
    <p><a href="https://ffmpeg.org/download.html">ffmpeg</a> (Optional but recommended)</p>
  </li>
</ul>

<h2 id="project-overview">Project Overview</h2>

<p>Let’s take a look at how the application will work.</p>

<ul>
  <li>
    <p>You come up with a message that will sound scary/ominous. You can personalize it by adding your friend’s name or something private in the message to increase the impact.</p>
  </li>
  <li>
    <p>You create an audio file using Amazon Polly based on your crafted text.</p>
  </li>
  <li>
    <p>Optional (but recommended as it makes the experience more fun), you use ffmpeg to add some effects to your audio to add more spookiness.</p>
  </li>
  <li>
    <p>You upload the final audio to Amazon S3.</p>
  </li>
  <li>
    <p>You call your friend and play the audio by using Twilio Voice.</p>
  </li>
</ul>

<p>Now that you understand how the application will work let’s get started.</p>

<h2 id="set-up-aws-iam-user">Set up AWS IAM User</h2>

<p>You will need credentials to deploy your application to AWS from the command line. To create the credentials, follow the steps below:</p>

<p>First, go to the <a href="https://us-east-1.console.aws.amazon.com/iamv2/home#/users">AWS IAM Users Dashboard</a> and click the Add users button.</p>

<p>Enter the user name, such as twilio-webhook-user and tick the Access key - Programmatic access checkbox:</p>

<p><img src="/images/vpblogimg/2026/08/Make-a-Spooky-Phone-Call-using-Twilio-Voice-and-Amazon-Polly/01.png" alt="Make a Spooky Phone Call using Twilio Voice and Amazon Polly - image 1" /></p>

<p>Click the Next: Permissions button at the bottom right.</p>

<p>Then, select Attach existing policies directly and select AdministratorAccess:</p>

<p><img src="/images/vpblogimg/2026/08/Make-a-Spooky-Phone-Call-using-Twilio-Voice-and-Amazon-Polly/02.png" alt="Make a Spooky Phone Call using Twilio Voice and Amazon Polly - image 2" /></p>

<p>Click the Next: Tags button at the bottom right. Tags are optional (and quite valuable information), and it’s a good practice to add descriptive tags to the resources you create. Since this is a demo project, you can skip this step and click the Next: Review button at the bottom.</p>

<p>Confirm your selection on the review page. It should look like this:</p>

<p><img src="/images/vpblogimg/2026/08/Make-a-Spooky-Phone-Call-using-Twilio-Voice-and-Amazon-Polly/03.png" alt="Make a Spooky Phone Call using Twilio Voice and Amazon Polly - image 3" /></p>

<p>Then, click the Create user button.</p>

<p>In the final step of the user creation process, you should see your credentials for the first and the last time.</p>

<p>!!!warning</p>

<p>Take note of your Access key ID and Secret access key before you press the close button.</p>

<p>!!!</p>

<p>Now, open a terminal window and run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws configure
</code></pre></div></div>

<p>You should see a prompt for AWS Access Key ID. Copy and paste your access key ID and press enter.</p>

<p>Then, copy and paste your secret access key and press enter.</p>

<p><img src="/images/vpblogimg/2026/08/Make-a-Spooky-Phone-Call-using-Twilio-Voice-and-Amazon-Polly/04.png" alt="Make a Spooky Phone Call using Twilio Voice and Amazon Polly - image 4" /></p>

<p>When prompted, type us-east-1 as the default region name and press enter.</p>

<p>!!!info</p>

<p>In this example, I will use the us-east-1 region. Regions are geographical locations where AWS have their data centers. It is a good practice to deploy as close to your customers as possible for production deployments to reduce latency. Since this is a demo project, you can use us-east-1 for convenience as it’s the default region in AWS Management Console. You can find more on AWS regions in this document: <a href="https://aws.amazon.com/about-aws/global-infrastructure/regions_az/">Regions and Availability Zones</a>.</p>

<p>!!!</p>

<p>As the default output format, type json and press enter.</p>

<p>To confirm you have configured your AWS profile correctly, run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws configure list
</code></pre></div></div>

<p>The output should look like this:</p>

<p><img src="/images/vpblogimg/2026/08/Make-a-Spooky-Phone-Call-using-Twilio-Voice-and-Amazon-Polly/05.png" alt="Make a Spooky Phone Call using Twilio Voice and Amazon Polly - image 5" /></p>

<p>Now that you have set up your AWS credentials, you can move on to setting up the code.</p>

<h2 id="create-s3-bucket-to-store-mp3-files">Create S3 Bucket to Store MP3 Files</h2>

<p>The audio files you create and modify locally won’t be accessible for Twilio to play. To fix that issue, you will need a public storage area. In this tutorial, you will use an <a href="https://aws.amazon.com/s3/">Amazon S3</a> bucket for storage.</p>

<p>In a terminal window, run the following command to create a new bucket:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws s3api create-bucket <span class="nt">--bucket</span>  <span class="o">{</span>Your bucket name<span class="o">}</span>
</code></pre></div></div>

<p>!!!info</p>

<p><code class="language-plaintext highlighter-rouge">{Your bucket name}</code> has to follow these <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html">bucket naming rules documented by AWS</a>.</p>

<p>!!!</p>

<p>This bucket will be used by <a href="https://aws.amazon.com/polly/">Amazon Polly</a> to store text-to-speech outputs. Also, you’re going to upload modified audio files to this bucket as well.</p>

<p>For Polly to store outputs in this bucket, you need to give write permissions to it. This can be achieved by setting the bucket policy as below:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"Version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2012-10-17"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"Statement"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"Sid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"PublicRead"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"Effect"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Allow"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"Principal"</span><span class="p">:</span><span class="w"> </span><span class="s2">"*"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"Action"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="s2">"s3:GetObject"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"s3:PutObject"</span><span class="w">
      </span><span class="p">],</span><span class="w">
      </span><span class="nl">"Resource"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="s2">"arn:aws:s3:::{Your bucket name}"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"arn:aws:s3:::{Your bucket name}/*"</span><span class="w">
      </span><span class="p">]</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Store the above JSON in a file such as policy.json and replace <code class="language-plaintext highlighter-rouge">{Your bucket name}</code> with the actual name of your S3 bucket.</p>

<p>Then, run the following command to update permissions on the bucket:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws s3api put-bucket-policy <span class="nt">--bucket</span> <span class="o">{</span>Your bucket name<span class="o">}</span> <span class="nt">--policy</span> file://policy.json
</code></pre></div></div>

<p>!!!warning</p>

<p>This policy is too broad, and I’d not recommend it for a serious project, but for a quick and fun project such as this one, it’s fine. After you’ve finished this tutorial, make sure to delete the contents and the bucket itself.</p>

<p>!!!</p>

<h2 id="create-audio-messages-with-amazon-polly">Create Audio Messages with Amazon Polly</h2>

<p>First, let’s focus on preparing the audio message. At this point, you should have an idea of your message to convert to audio.</p>

<p>Start by creating a console application:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir </span>SpookySeasonPrankPrepareAudio
<span class="nb">cd </span>SpookySeasonPrankPrepareAudio
dotnet new console
</code></pre></div></div>

<p>The easiest way to use AWS APIs with C# is to use their .NET SDK. They have a modular structure so you can only include the libraries for the services you will use. In this section, you will use <a href="https://aws.amazon.com/polly/">Amazon Polly</a> so go ahead and run the following command to include that package only:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package AWSSDK.Polly
</code></pre></div></div>

<p>Open the project in your IDE and replace the contents of Program.cs with the code below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Amazon.Polly</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Amazon.Polly.Model</span><span class="p">;</span>
<span class="k">using</span> <span class="p">(</span><span class="kt">var</span> <span class="n">pollyClient</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">AmazonPollyClient</span><span class="p">())</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">startSpeechSynthesisTaskRequest</span> <span class="p">=</span> <span class="k">new</span> <span class="n">StartSpeechSynthesisTaskRequest</span>
    <span class="p">{</span>
        <span class="n">Text</span> <span class="p">=</span> <span class="s">"James! I know what you did last summer!. You're not getting away with it this time!"</span><span class="p">,</span>
        <span class="n">VoiceId</span> <span class="p">=</span> <span class="n">VoiceId</span><span class="p">.</span><span class="n">Matthew</span><span class="p">,</span>
        <span class="n">OutputFormat</span> <span class="p">=</span> <span class="n">OutputFormat</span><span class="p">.</span><span class="n">Mp3</span><span class="p">,</span>
        <span class="n">OutputS3BucketName</span> <span class="p">=</span> <span class="s">"{Your bucket name}"</span><span class="p">,</span>
        <span class="n">OutputS3KeyPrefix</span> <span class="p">=</span> <span class="s">"polly-output"</span><span class="p">,</span>
    <span class="p">};</span>
    <span class="k">await</span> <span class="n">pollyClient</span><span class="p">.</span><span class="nf">StartSpeechSynthesisTaskAsync</span><span class="p">(</span><span class="n">startSpeechSynthesisTaskRequest</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This message is crafted for my imaginary friend James. I don’t know anybody named James, so it’s just a test name that I used. Tailor your spooky message to achieve your own “evil” goal! 🎃</p>

<p>You can also change the voice that Amazon Polly is going to use. Again, there are no right or wrong answers here. Just play around with the options and use the ones you like.</p>

<p>Run the application using <code class="language-plaintext highlighter-rouge">dotnet run</code>.</p>

<p>Now open <a href="https://us-east-1.console.aws.amazon.com/polly/home/SynthesisTasks">Amazon Polly dashboard</a>, and you should see your synthesis task:</p>

<p><img src="/images/vpblogimg/2026/08/Make-a-Spooky-Phone-Call-using-Twilio-Voice-and-Amazon-Polly/06.png" alt="Make a Spooky Phone Call using Twilio Voice and Amazon Polly - image 6" /></p>

<p>It also shows the URL of the output file. Download the file by using AWS CLI:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws s3api get-object <span class="nt">--bucket</span> <span class="o">{</span>Your bucket name<span class="o">}</span> <span class="nt">--key</span> polly-output.<span class="o">{</span>Your task ID<span class="o">}</span>.mp3 polly-output-original.mp3
</code></pre></div></div>

<p>Replace the placeholders for the bucket name and task ID. You can use any name for the local file name.</p>

<p>You can find the MP3 files on the project’s <a href="https://github.com/cloudinternals/halloween-prank-using-twilio-voice-and-amazon-polly">GitHub repository</a>.</p>

<p>Now that you have a semi-spooky customized audio message, move on to the next section to add effects to spook it up a notch!</p>

<h2 id="audio-effects-with-ffmpeg">Audio Effects with ffmpeg</h2>

<p>I’m by no means an expert in audio mixing and editing. So if you are blessed with those skills, feel free to do your thing and move on to the next section.</p>

<p>In this tutorial, you will add a rather spooky background to the previous audio generated by Amazon Polly.</p>

<p>One good resource for finding free assets is <a href="https://pixabay.com/">pixabay.com</a>. The sound clip I used in this tutorial is called <a href="https://cdn.pixabay.com/download/audio/2022/03/16/audio_4944318475.mp3?filename=halloween-impact-05-93808.mp3">Halloween Impact 05 by Charlie Raven</a>. Open the link in a new tab and the download should start automatically. Alternatively, you can go to <a href="http://pixabay.com">pixabay.com</a> directly, search for the name of the audio and click the Download button next to it.</p>

<p>Copy the downloaded file next to the Polly-generated file (polly-output-original.mp3) and run the following command in a terminal window in the same folder:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ffmpeg <span class="nt">-i</span> polly-output-original.mp3 <span class="nt">-i</span> halloween-impact-05-93808.mp3 <span class="nt">-filter_complex</span> <span class="s2">"[1:a]adelay=2s:all=1[a1];[0:a][a1]amix=inputs=2[a]"</span> <span class="nt">-map</span> <span class="s2">"[a]"</span> output.mp3
</code></pre></div></div>

<p>The command above merges both files and adds a 2-second delay to the background audio. This delay is to avoid the loud sound drowning out the name.</p>

<p>This is where you can get creative and experiment with various combinations to produce the scariest results. Once you’re happy with the result, upload it your S3 bucket by running the command below:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws s3api put-object <span class="nt">--bucket</span> <span class="o">{</span>Your bucket name<span class="o">}</span> <span class="nt">--key</span> output.mp3 <span class="nt">--body</span> output.mp3 <span class="nt">--content-type</span> audio/mpeg
</code></pre></div></div>

<h2 id="create-a-console-application-to-use-twilio-voice">Create a Console Application to Use Twilio Voice</h2>

<p>Now that you have your final audio ready, the last step is to call your “victim” and play it.</p>

<p>To achieve this, create a new console application.</p>

<p>Back in your terminal window, run the following commands:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> ..
<span class="nb">mkdir </span>SpookySeasonPrank
<span class="nb">cd </span>SpookySeasonPrank
dotnet new console
</code></pre></div></div>

<p>Then, while still in the terminal, add Twilio .NET SDK via NuGet:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package Twilio
</code></pre></div></div>

<p>Twilio SDK makes it easy to generate <a href="https://www.twilio.com/docs/glossary/what-is-twilio-markup-language-twiml">TwiML</a> and interact with <a href="https://www.twilio.com/docs/usage/api">Twilio API</a>.</p>

<p>To make phone calls using Twilio API, you will need your Account Sid and Auth Token, which you can obtain from the <a href="https://console.twilio.com/">Twilio Console</a>.</p>

<p>Open the <a href="https://console.twilio.com/">Twilio Console</a> and log in to your account.</p>

<p>In the welcome screen, you should see your Account SID and Auth Token at the bottom in the Account Info section:</p>

<p><img src="/images/vpblogimg/2026/08/Make-a-Spooky-Phone-Call-using-Twilio-Voice-and-Amazon-Polly/07.png" alt="Make a Spooky Phone Call using Twilio Voice and Amazon Polly - image 7" /></p>

<p>You can use environment variables or a vault service to store these values, but for local development, you can use <a href="https://docs.microsoft.com/en-us/aspnet/core/security/app-secrets">dotnet user secrets</a> by running the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet user-secrets init
</code></pre></div></div>

<p>Then, add your Account SID and Auth Token by replacing the placeholders with actual values and running the commands below:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet user-secrets <span class="nb">set </span>Twilio:AccountSid <span class="o">{</span>Your Account Sid<span class="o">}</span>
dotnet user-secrets <span class="nb">set </span>Twilio:AuthToken <span class="o">{</span>Your auth Token<span class="o">}</span>
</code></pre></div></div>

<p>Storing them in user secrets would not help much if you didn’t have a way to retrieve them. To get the values back, you’re going to use the .NET configuration builder which is in <a href="https://www.nuget.org/packages/Microsoft.Extensions.Configuration">Microsoft.Extensions.Configuration NuGet package</a>.</p>

<p>In the terminal, add the following configuration extension libraries:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Configuration.UserSecrets
</code></pre></div></div>

<p>You can also add command line or environment variable providers, but you won’t use them in this example.</p>

<p>Open the project with your IDE and update Program.cs with the code below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.Extensions.Configuration</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.Rest.Api.V2010.Account</span><span class="p">;</span>
<span class="n">IConfiguration</span> <span class="n">config</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">ConfigurationBuilder</span><span class="p">()</span>
    <span class="p">.</span><span class="n">AddUserSecrets</span><span class="p">&lt;</span><span class="n">Program</span><span class="p">&gt;(</span><span class="n">optional</span><span class="p">:</span> <span class="k">true</span><span class="p">,</span> <span class="n">reloadOnChange</span><span class="p">:</span> <span class="k">false</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">Build</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">twilioAccountSid</span> <span class="p">=</span> <span class="n">config</span><span class="p">[</span><span class="s">"Twilio:AccountSid"</span><span class="p">];</span>
<span class="kt">var</span> <span class="n">twilioAuthToken</span> <span class="p">=</span> <span class="n">config</span><span class="p">[</span><span class="s">"Twilio:AuthToken"</span><span class="p">];</span>
<span class="n">TwilioClient</span><span class="p">.</span><span class="nf">Init</span><span class="p">(</span><span class="n">twilioAccountSid</span><span class="p">,</span> <span class="n">twilioAuthToken</span><span class="p">);</span>
<span class="n">CallResource</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span>
    <span class="n">url</span><span class="p">:</span> <span class="k">new</span> <span class="nf">Uri</span><span class="p">(</span><span class="s">"https://{Your bucket name}.s3.amazonaws.com/output.mp3"</span><span class="p">),</span>
    <span class="n">method</span><span class="p">:</span> <span class="n">Twilio</span><span class="p">.</span><span class="n">Http</span><span class="p">.</span><span class="n">HttpMethod</span><span class="p">.</span><span class="n">Get</span><span class="p">,</span> 
    <span class="n">to</span><span class="p">:</span> <span class="k">new</span> <span class="n">Twilio</span><span class="p">.</span><span class="n">Types</span><span class="p">.</span><span class="nf">PhoneNumber</span><span class="p">(</span><span class="s">"{Your victim's phone number}"</span><span class="p">),</span>
    <span class="k">from</span><span class="p">:</span> <span class="k">new</span> <span class="n">Twilio</span><span class="p">.</span><span class="n">Types</span><span class="p">.</span><span class="nf">PhoneNumber</span><span class="p">(</span><span class="s">"{Your Twilio Phone Number}"</span><span class="p">)</span>
<span class="p">);</span>
</code></pre></div></div>

<p>Replace {Your bucket name}, {Your victim’s phone number} and {Your Twilio Phone Number} with the actual values. You can obtain your Twilio phone number from Twilio Console (It should be shown right below your Account SID and Auth Token).</p>

<p>I’d recommend testing the application with your phone first. Once you’ve confirmed your message sounds as scary as you wanted to sound, you can replace your number with your victim’s phone number and execute your evil plan by running:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet run
</code></pre></div></div>

<p>Happy Spooky Season!</p>

<h2 id="conclusion">Conclusion</h2>

<p>I hope you enjoyed following this tutorial as much as I enjoyed writing it. Having programmatic access to phone calls and SMS messages opens many possibilities. Combined with other cloud services, you can quickly create a wide variety of projects. If you enjoy developing with Twilio and .NET, you might want to take a look at these articles too:</p>

<ul>
  <li>
    <p><a href="https://www.twilio.com/blog/better-configure-csharp-and-dotnet-apps-for-sendgrid">How to better configure C# and .NET applications for SendGrid</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/respond-to-twilio-webhooks-using-aws-lambda-and-dotnet">Respond to Twilio Webhooks using AWS Lambda and .NET</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/2018/08/introducing-50-additional-text-to-speech-voices-with-amazon-polly-integration.html">Introducing 50+ additional Text-to-Speech voices with Amazon Polly Integration</a></p>
  </li>
</ul>
]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates]]></title>
    <link href="https://volkanpaksoy.com/archive/2026/08/05/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/"/>
    <updated>2026-08-05T12:50:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2026/08/05/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates</id>
    <content type="html"><![CDATA[<blockquote>
  <p>This article was originally published on the <a href="https://www.twilio.com/en-us/blog/developers/community/send-rss-feed-digest-email-with-csharp-and-dynamic-email-templates">Twilio Blog</a>.</p>
</blockquote>

<p>In this article, you will learn how to create a nicely formatted dynamic email using the Twilio Blog RSS feed as source data and send it via the SendGrid API. You will first look into creating the template with test data. Then you will learn how to parse RSS and HTML and send the emails with dynamic data.</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>You’ll need the following things for this tutorial:</p>

<ul>
  <li>
    <p>A free Twilio SendGrid account. <a href="https://signup.sendgrid.com/">Sign up for a SendGrid account here</a> to send up to 100 emails per day completely free of charge</p>
  </li>
  <li>
    <p>An OS that supports .NET (Windows/macOS/Linux)</p>
  </li>
  <li>
    <p><a href="https://dotnet.microsoft.com/en-us/download/dotnet/6.0">.NET 6.0 SDK (newer and older versions may work too)</a></p>
  </li>
  <li>
    <p>A code editor or IDE (Recommended: <a href="https://code.visualstudio.com/Download">Visual Studio Code</a> with <a href="https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp">the C# plugin</a>, <a href="https://visualstudio.microsoft.com/">Visual Studio</a>, or <a href="https://www.jetbrains.com/rider/">JetBrains Rider</a>)</p>
  </li>
  <li>
    <p>SendGrid API key (See <a href="https://docs.sendgrid.com/ui/account-and-settings/api-keys">Manage SendGrid API Keys</a>)</p>
  </li>
  <li>
    <p>A verified Sender email or domain to send emails from (See <a href="https://docs.sendgrid.com/ui/sending-email/senders#adding-a-sender">Adding a Sender</a>)</p>
  </li>
  <li>
    <p><a href="https://git-scm.com/downloads">Git CLI</a></p>
  </li>
</ul>

<h2 id="create-a-dynamic-email-template">Create a Dynamic Email Template</h2>

<p>Follow the steps below to create the dynamic email template for RSS feed digest email:</p>

<p>Go to the <a href="https://mc.sendgrid.com/dynamic-templates">Dynamic Templates dashboard</a> and click Create a Dynamic Template.
<img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/01.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 1" /></p>

<p>Enter the name of your template (e.g. blog-rss-feed-digest-email) and click Create.
<img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/02.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 2" /></p>

<p>Expand your template and click Add Version.
<img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/03.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 3" /></p>

<p>Hover over Blank Template and click the Select button.
<img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/04.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 4" /></p>

<p>Click the Select button in the Design Editor section.
<img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/05.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 5" /></p>

<p>Update Version Name to <code class="language-plaintext highlighter-rouge">version-1</code>, and then update the Subject to <code class="language-plaintext highlighter-rouge">{{subject}}</code>.</p>

<p>Delete the Unsubscribe module by hovering over it and clicking the trash can icon.</p>

<p><img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/06.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 6" /></p>

<p>Confirm the deletion by clicking Confirm button in the dialog box.</p>

<p><img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/07.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 7" /></p>

<p>!!!warning</p>

<p>The sample application shown in this article is meant to be used for learning purposes only. It’s meant to send emails to yourself. If you intend to send emails to third parties, make sure to click Learn more button in the dialog or visit SendGrid documentation on <a href="https://docs.sendgrid.com/ui/sending-email/global-unsubscribes">Global Unsubscribes</a> and <a href="https://docs.sendgrid.com/ui/sending-email/group-unsubscribes">Group Unsubscribes</a>.</p>

<p>!!!</p>

<p>Now click the Build tab and drag the Code module into the design area that says Drag Module Here.
<img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/08.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 8" /></p>

<p>The edit module screen will automatically appear. Paste the following code inside the editor and click Update.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;!DOCTYPE html&gt;</span>
<span class="nt">&lt;html</span> <span class="na">lang=</span><span class="s">"en"</span><span class="nt">&gt;</span>
<span class="nt">&lt;head&gt;</span>
    <span class="nt">&lt;meta</span> <span class="na">charset=</span><span class="s">"UTF-8"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;style&gt;</span>
        <span class="nt">body</span> <span class="p">{</span> <span class="nl">background-color</span><span class="p">:</span> <span class="no">lightyellow</span><span class="p">;</span> <span class="p">}</span>
        <span class="nt">h2</span><span class="o">,</span> <span class="nt">h3</span> <span class="p">{</span> <span class="nl">color</span><span class="p">:</span> <span class="no">green</span><span class="p">;</span> <span class="nl">margin-left</span><span class="p">:</span> <span class="m">40px</span><span class="p">;</span> <span class="p">}</span>
        <span class="nt">table</span> <span class="p">{</span> <span class="nl">border-collapse</span><span class="p">:</span> <span class="nb">collapse</span><span class="p">;</span> <span class="p">}</span>
        <span class="nt">tr</span><span class="nc">.separated</span> <span class="nt">td</span> <span class="p">{</span> <span class="nl">border-top</span><span class="p">:</span> <span class="m">1px</span> <span class="nb">dashed</span> <span class="no">black</span><span class="p">;</span> <span class="nl">padding</span><span class="p">:</span> <span class="m">5px</span><span class="p">;</span> <span class="p">}</span>
        <span class="nt">a</span> <span class="p">{</span> <span class="nl">text-decoration</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span> <span class="p">}</span>
        <span class="nc">.postTitle</span> <span class="p">{</span> <span class="nl">font-size</span><span class="p">:</span> <span class="m">20px</span><span class="p">;</span> <span class="p">}</span>
        <span class="nc">.readMoreButton</span> <span class="p">{</span>
            <span class="nl">background-color</span><span class="p">:</span> <span class="m">#04AA6D</span><span class="p">;</span>
            <span class="nl">border</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
            <span class="nl">color</span><span class="p">:</span> <span class="no">white</span><span class="p">;</span>
            <span class="nl">padding</span><span class="p">:</span> <span class="m">8px</span><span class="p">;</span>
            <span class="nl">text-align</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
            <span class="nl">text-decoration</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
            <span class="nl">display</span><span class="p">:</span> <span class="n">inline-block</span><span class="p">;</span>
            <span class="nl">font-size</span><span class="p">:</span> <span class="m">12px</span><span class="p">;</span>
            <span class="nl">margin</span><span class="p">:</span> <span class="m">4px</span> <span class="m">2px</span><span class="p">;</span>
            <span class="nl">cursor</span><span class="p">:</span> <span class="nb">pointer</span><span class="p">;</span>
            <span class="nl">border-radius</span><span class="p">:</span> <span class="m">2px</span><span class="p">;</span>
        <span class="p">}</span>
        <span class="nc">.headerImage</span> <span class="p">{</span> <span class="nl">padding-right</span><span class="p">:</span> <span class="m">10px</span><span class="p">;</span> <span class="p">}</span>
    <span class="nt">&lt;/style&gt;</span>
<span class="nt">&lt;/head&gt;</span>
<span class="nt">&lt;body&gt;</span>
<span class="nt">&lt;div&gt;</span>
    <span class="nt">&lt;h2&gt;</span>Hello, {{recipientName}}<span class="nt">&lt;/h2&gt;</span>
    <span class="nt">&lt;h3&gt;</span>Here are the latest blog posts from Twilio Blog:<span class="nt">&lt;/h3&gt;</span>
    {{#each blogPostList}}
    <span class="nt">&lt;table&gt;</span>
        <span class="nt">&lt;tr</span> <span class="na">class=</span><span class="s">"separated"</span><span class="nt">&gt;</span>
            <span class="nt">&lt;td&gt;&lt;img</span> <span class="na">class=</span><span class="s">"headerImage"</span> <span class="na">src=</span><span class="s">"{{this.headerImageUrl}}"</span> <span class="na">width=</span><span class="s">"200"</span> <span class="na">height=</span><span class="s">"112"</span><span class="nt">&gt;&lt;/td&gt;</span>
            <span class="nt">&lt;td&gt;</span>
                <span class="nt">&lt;a</span> <span class="na">class=</span><span class="s">"postTitle"</span> <span class="na">href=</span><span class="s">"{{this.Link}}"</span><span class="nt">&gt;</span> {{this.title}} <span class="nt">&lt;/a&gt;</span>
                <span class="nt">&lt;p&gt;</span>by <span class="nt">&lt;b&gt;</span>{{this.author}}<span class="nt">&lt;/b&gt;</span> - <span class="nt">&lt;b&gt;</span>{{this.publishDate}}<span class="nt">&lt;/b&gt;&lt;/p&gt;</span>
                {{#if this.categories}}
                    <span class="nt">&lt;p&gt;</span>Categories: {{this.categories}}<span class="nt">&lt;/p&gt;</span>
                {{/if}}
                <span class="nt">&lt;p&gt;</span>{{{this.description}}}<span class="nt">&lt;/p&gt;</span>
                <span class="nt">&lt;p&gt;</span>
                    <span class="nt">&lt;a</span> <span class="na">class=</span><span class="s">"readMoreButton"</span> <span class="na">href=</span><span class="s">"{{this.link}}"</span><span class="nt">&gt;</span>Read more<span class="nt">&lt;/a&gt;</span>
                <span class="nt">&lt;/p&gt;</span>
            <span class="nt">&lt;/td&gt;</span>
        <span class="nt">&lt;/tr&gt;</span>
    <span class="nt">&lt;/table&gt;</span>
    {{/each}}
<span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;div&gt;</span>
    <span class="nt">&lt;h2&gt;</span>Last Build Date: {{lastBuildDate}}<span class="nt">&lt;/h2&gt;</span>
<span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/body&gt;</span>
<span class="nt">&lt;/html&gt;</span>
</code></pre></div></div>

<p>Click Save in the top menu.</p>

<p><img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/09.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 9" /></p>

<h2 id="handlebars-templating-language">Handlebars Templating Language</h2>

<p>SendGrid uses the Handlebars Templating Language to handle variable substitution and add some logic to the templates. You can find all the supported features on SendGrid documentation: <a href="https://docs.sendgrid.com/for-developers/sending-email/using-handlebars">Using Handlebars</a>. You might also want to check out <a href="https://www.twilio.com/blog/send-emails-with-csharp-handlebars-templating-and-dynamic-email-templates">this</a> article which also uses Handlebars templating.</p>

<p>Let me show you some of the Handlebars features used in the dynamic email template.</p>

<h3 id="conditionals">Conditionals</h3>

<p>Twilio blog posts can belong to multiple categories. Also, in some cases, they don’t have any categories. So, instead of showing a blank Categories line in the email, you can hide the line if the post does not have any categories.</p>

<p>To check if a string is empty or not, we can use a simple if statement as shown below:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{{#if this.Categories}}
  <span class="nt">&lt;p&gt;</span>Categories: {{this.Categories}}<span class="nt">&lt;/p&gt;</span>
{{/if}}
</code></pre></div></div>

<p>If the value of <code class="language-plaintext highlighter-rouge">this.Categories</code> variable is an empty string, it evaluates to false. In that case, the <code class="language-plaintext highlighter-rouge">p</code> element will be hidden.</p>

<h3 id="html-injection">HTML Injection</h3>

<p>As you will see later in the Parsing HTML section, the full HTML post is included in the XML, and you will parse the first paragraph as HTML. In the email, you need to embed this block as HTML; otherwise, it would look broken if other HTML elements were inside the paragraph.</p>

<p>To inject HTML, you can just use triple curly braces as shown below:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;p&gt;</span>{{{this.Description}}}<span class="nt">&lt;/p&gt;</span>
</code></pre></div></div>

<p>!!!warning
Keep in mind that whenever you are using three curly braces, the variable will not be encoded and <a href="https://www.twilio.com/blog/prevent-email-html-injection-in-csharp-and-dotnet">susceptible to HTML injection</a>. If this variable holds user input, this can be risky!
Make sure to use the <code class="language-plaintext highlighter-rouge">HtmlEncoder</code> in .NET to encode user input before passing it into the three curly braces.
!!!</p>

<h3 id="iterations">Iterations</h3>

<p>In the example, you will send an RSS feed digest, meaning there will be multiple blog post sections in the email. Handlebars supports arrays and iterations. You can access each item in the array by using the <code class="language-plaintext highlighter-rouge">each</code> keyword. In the example, you did this for the <code class="language-plaintext highlighter-rouge">blogPostList</code> array:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{{#each blogPostList}}
…
<span class="nt">&lt;p&gt;</span>{{{this.Description}}}<span class="nt">&lt;/p&gt;</span>
…
{{/each}}
</code></pre></div></div>

<p>Between <code class="language-plaintext highlighter-rouge">{{#each blogPostList}}</code> and <code class="language-plaintext highlighter-rouge">{{/each}}</code>, you can access each item in the array by using the <code class="language-plaintext highlighter-rouge">this</code> keyword.</p>

<h2 id="test-the-template">Test the Template</h2>

<p>The SendGrid designer allows you to preview the rendered output by using hard-coded test data. This is a handy feature as you get to see all the variable substitutions in action right in the designer.</p>

<p>To test your template, click the Preview button in the top menu. You should see something like this:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/10.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 10" /></p>

<p>Next, click Show Test Data to open the data panel on the left.</p>

<p>I obtained some test data manually from the actual Twilio Blog to make the test more realistic. Paste the following JSON into the data panel:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"subject"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Latest Posts - 07 July 2022"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"recipientName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Volkan"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"lastBuildDate"</span><span class="p">:</span><span class="w"> </span><span class="s2">"7 July 2022, 12:34"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"blogPostList"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Automatically Forward Text Messages with No Code Using Twilio Studio"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"link"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://www.twilio.com/blog/automatically-forward-text-messages-no-code-studio"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"headerImageUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://twilio-cms-prod.s3.amazonaws.com/images/Copy_of_C03_Blog_Text_2.width-808.png"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This article explains how to forward any incoming text messages sent to your Twilio phone number to another number automatically using a no-code solution called Twilio Studio."</span><span class="p">,</span><span class="w">
      </span><span class="nl">"author"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Ashley Boucher"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"publishDate"</span><span class="p">:</span><span class="w"> </span><span class="s2">"06 July 2022"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"categories"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="s2">"Code, Tutorials and Hacks"</span><span class="w">
      </span><span class="p">]</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Super SIM now offers VPN connectivity for your IoT devices"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"link"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://www.twilio.com/blog/vpn-iot-devices"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"headerImageUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://twilio-cms-prod.s3.amazonaws.com/images/VPN_-_Social_Banner.width-808.png"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"I am excited to announce that Super SIM now has VPN (Virtual Private Network) support, enabling you to set up secure private networks between Twilio and your application data centers and have your Super SIM connected devices use these private networks. With regular Internet breakout, the traffic from devices using Super SIM will go over the Internet and get routed to your application data center. When VPN is used, the same traffic is sent over a secure and private tunnel as shown below:"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"author"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Vijay Devarapalli"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"publishDate"</span><span class="p">:</span><span class="w"> </span><span class="s2">"06 July 2022"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"categories"</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>You should now see the rendered output on the right-hand side:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/11.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 11" /></p>

<p>As you can see, by using the preview feature, you can see the final output without writing any code and sending any emails. Even though the preview is quite accurate, you might want to see it in your inbox as an email. You can also do that in the designer.</p>

<p>Click the Design button on the top menu.</p>

<p>Then, expand Test Your Email section on the left panel:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/12.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 12" /></p>

<p>By default, the From Address field is populated by the email address you used when you created your SendGrid account. However, you can replace it with your verified sender address if you like.</p>

<p>Fill in the Email Addresses field with your recipient’s email address. You can test up to 10 recipients.</p>

<p>Click Send Test Message button. Then, check your inbox, and you should see an email that looks like this:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/13.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 13" /></p>

<p>It looks like the real thing, except that SendGrid prepends the subject with “Test - “.</p>

<p>So far, you have developed an email template, reviewed the rendered output and sent out actual emails using the designer. Now, it’s time to write some code to send the emails programmatically using the same hard-coded data.</p>

<h2 id="set-up-your-project-to-send-emails">Set up your Project to Send Emails</h2>

<p>This tutorial will start from an existing git repository. To get the application up and running, follow the steps below:</p>

<p>Clone the <a href="https://github.com/Dev-Power/send-rss-feed-digest-email-with-sendgrid-dynamic-templates">GitHub repository</a>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/Dev-Power/send-rss-feed-digest-email-with-sendgrid-dynamic-templates.git <span class="nt">--branch</span> 00-starter-project
</code></pre></div></div>

<p>Alternatively, you can <a href="https://github.com/Dev-Power/send-rss-feed-digest-email-with-sendgrid-dynamic-templates">open the repository</a>, switch to the 00-starter-project branch and then click Code and Download ZIP button.</p>

<p><img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/14.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 14" /></p>

<p>As a third option, you can download the zip file by clicking on <a href="https://github.com/Dev-Power/send-rss-feed-digest-email-with-sendgrid-dynamic-templates/archive/refs/heads/00-starter-project.zip">this</a> link.</p>

<p>Navigate into the project folder:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd </span>send-rss-feed-digest-email-with-sendgrid-dynamic-templates
<span class="nb">cd </span>src/RssFeedDigestEmailer.Cli
</code></pre></div></div>

<p>To store your SendGrid API key securely, add it to the project user secrets by running the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet user-secrets <span class="nb">set </span>SendGridSettings:ApiKey <span class="o">[</span>YOUR_SENDGRID_API_KEY]
</code></pre></div></div>

<p>Replace <code class="language-plaintext highlighter-rouge">[YOUR SENDGRID API KEY]</code> with the SendGrid API key you created earlier.</p>

<p>Update appsettings.json and configure the email settings sections:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"emailSettings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"senderEmailAddress"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
    </span><span class="nl">"senderDisplayName"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
    </span><span class="nl">"recipientEmailAddress"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
    </span><span class="nl">"recipientDisplayName"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
    </span><span class="nl">"templateId"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Update</p>

<ul>
  <li>
    <p><code class="language-plaintext highlighter-rouge">senderEmailAddress</code> with your SendGrid sender email address,</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">senderDisplayName</code> with any name that you would like the recipient to see,</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">recipientEmailAddress</code> with the email address you want to email,</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">recipientDisplayName</code> with the name of the recipient,</p>
  </li>
  <li>
    <p>and the <code class="language-plaintext highlighter-rouge">templateId</code> with the ID of the template you created earlier. You can obtain the Template ID by expanding it in the dashboard:</p>
  </li>
</ul>

<p><img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/15.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 15" /></p>

<p>Now that the project has been set up, review the important parts of the code.</p>

<p>Currently, the project contains an <code class="language-plaintext highlighter-rouge">EmailService</code> class to talk to the SendGrid API using the <code class="language-plaintext highlighter-rouge">SendGridClient</code> class. It uses a data provider to get the template data. In this example, you have a <code class="language-plaintext highlighter-rouge">JsonDataProvider</code> class that looks like this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">object</span><span class="p">&gt;</span> <span class="nf">GetEmailData</span><span class="p">()</span>
<span class="p">{</span>
    <span class="kt">string</span> <span class="n">jsonFilePath</span> <span class="p">=</span> <span class="s">"./Data/DummyData.json"</span><span class="p">;</span>
    <span class="kt">string</span> <span class="n">rawContents</span> <span class="p">=</span> <span class="k">await</span> <span class="n">File</span><span class="p">.</span><span class="nf">ReadAllTextAsync</span><span class="p">(</span><span class="n">jsonFilePath</span><span class="p">);</span>
    <span class="k">return</span> <span class="n">JsonConvert</span><span class="p">.</span><span class="nf">DeserializeObject</span><span class="p">(</span><span class="n">rawContents</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>It reads the data from a hard-coded JSON file named DummyData.json under the Data folder. The contents of DummyData.json are exactly the same as you used in the designer preview.</p>

<p>Having a separate provider for data makes the email service data-agnostic. The <code class="language-plaintext highlighter-rouge">SendTemplatedEmail</code> implementation looks like this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">SendTemplatedEmail</span><span class="p">()</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">dynamicEmailData</span> <span class="p">=</span> <span class="k">await</span> <span class="n">_dataProvider</span><span class="p">.</span><span class="nf">GetEmailData</span><span class="p">();</span>
    <span class="kt">var</span> <span class="k">from</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">EmailAddress</span><span class="p">(</span><span class="n">_emailSettings</span><span class="p">.</span><span class="n">SenderEmailAddress</span><span class="p">,</span> <span class="n">_emailSettings</span><span class="p">.</span><span class="n">SenderDisplayName</span><span class="p">);</span>
    <span class="kt">var</span> <span class="n">to</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">EmailAddress</span><span class="p">(</span><span class="n">_emailSettings</span><span class="p">.</span><span class="n">RecipientEmailAddress</span><span class="p">,</span> <span class="n">_emailSettings</span><span class="p">.</span><span class="n">RecipientDisplayName</span><span class="p">);</span>
    <span class="kt">var</span> <span class="n">msg</span> <span class="p">=</span> <span class="n">MailHelper</span><span class="p">.</span><span class="nf">CreateSingleTemplateEmail</span><span class="p">(</span><span class="k">from</span><span class="p">,</span> <span class="n">to</span><span class="p">,</span> <span class="n">_emailSettings</span><span class="p">.</span><span class="n">TemplateId</span><span class="p">,</span> <span class="n">dynamicEmailData</span><span class="p">);</span>
    <span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">await</span> <span class="n">_sendGridClient</span><span class="p">.</span><span class="nf">SendEmailAsync</span><span class="p">(</span><span class="n">msg</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="n">IsSuccessStatusCode</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">"Email has been sent successfully"</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>As the email service does not construct the template data itself, you can use different data by swapping out <code class="language-plaintext highlighter-rouge">JsonDataProvider</code> with new classes that implement the <code class="language-plaintext highlighter-rouge">IDataProvider</code> interface.</p>

<p>The main program is set up like this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">IHost</span> <span class="n">host</span> <span class="p">=</span> <span class="n">Host</span><span class="p">.</span><span class="nf">CreateDefaultBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">ConfigureHostConfiguration</span><span class="p">(</span><span class="n">config</span> <span class="p">=&gt;</span>
    <span class="p">{</span>
        <span class="n">config</span>
            <span class="p">.</span><span class="nf">AddUserSecrets</span><span class="p">(</span><span class="n">Assembly</span><span class="p">.</span><span class="nf">GetExecutingAssembly</span><span class="p">(),</span> <span class="k">true</span><span class="p">,</span> <span class="k">false</span><span class="p">);</span>
    <span class="p">})</span>
    <span class="p">.</span><span class="nf">ConfigureServices</span><span class="p">((</span><span class="n">hostBuilderContext</span><span class="p">,</span> <span class="n">services</span><span class="p">)</span> <span class="p">=&gt;</span>
    <span class="p">{</span>
        <span class="n">services</span>
            <span class="p">.</span><span class="n">AddTransient</span><span class="p">&lt;</span><span class="n">IEmailService</span><span class="p">,</span> <span class="n">EmailService</span><span class="p">&gt;()</span>
            <span class="p">.</span><span class="n">AddTransient</span><span class="p">&lt;</span><span class="n">IDataProvider</span><span class="p">,</span> <span class="n">JsonDataProvider</span><span class="p">&gt;();</span>
        <span class="n">services</span>
            <span class="p">.</span><span class="nf">AddSendGrid</span><span class="p">(</span><span class="n">options</span> <span class="p">=&gt;</span> <span class="n">options</span><span class="p">.</span><span class="n">ApiKey</span> <span class="p">=</span> <span class="n">hostBuilderContext</span><span class="p">.</span><span class="n">Configuration</span><span class="p">[</span><span class="s">"SendGridSettings:ApiKey"</span><span class="p">]);</span>
        <span class="n">services</span>
            <span class="p">.</span><span class="n">Configure</span><span class="p">&lt;</span><span class="n">EmailSettings</span><span class="p">&gt;(</span><span class="n">hostBuilderContext</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetSection</span><span class="p">(</span><span class="s">"EmailSettings"</span><span class="p">));</span>
    <span class="p">})</span>
    <span class="p">.</span><span class="nf">Build</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">emailService</span> <span class="p">=</span> <span class="p">(</span><span class="n">EmailService</span><span class="p">)</span> <span class="n">ActivatorUtilities</span><span class="p">.</span><span class="nf">CreateInstance</span><span class="p">(</span><span class="n">host</span><span class="p">.</span><span class="n">Services</span><span class="p">,</span> <span class="k">typeof</span><span class="p">(</span><span class="n">EmailService</span><span class="p">));</span>
<span class="k">await</span> <span class="n">emailService</span><span class="p">.</span><span class="nf">SendTemplatedEmail</span><span class="p">();</span>
</code></pre></div></div>

<p>You can see in the setup, user secrets are added to the configuration by calling the <code class="language-plaintext highlighter-rouge">AddUserSecrets</code> method. This is required to read the API key from .NET user secrets as you configured in the previous section.</p>

<p>Also, <code class="language-plaintext highlighter-rouge">SendGridClient</code> is added to the Dependency Injection (DI) Container using the <code class="language-plaintext highlighter-rouge">SendGrid.Extensions.DependencyInjection</code> <a href="https://www.nuget.org/packages/SendGrid.Extensions.DependencyInjection/">NuGet package</a>. This way, you don’t have to instantiate the <code class="language-plaintext highlighter-rouge">SendGridClient</code> object manually inside the <code class="language-plaintext highlighter-rouge">EmailService</code>.</p>

<p>Finally, note that <code class="language-plaintext highlighter-rouge">JsonDataProvider</code> is registered for the <code class="language-plaintext highlighter-rouge">IDataProvider</code> interface. When you implement getting dynamic data from the RSS feed, you will only have to change the line below, and you won’t have to touch the <code class="language-plaintext highlighter-rouge">EmailService</code>:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">.</span><span class="n">AddTransient</span><span class="p">&lt;</span><span class="n">IDataProvider</span><span class="p">,</span> <span class="n">JsonDataProvider</span><span class="p">&gt;();</span>
</code></pre></div></div>

<p>Now that you’ve covered the main parts of the application, go ahead and run by running the command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet run
</code></pre></div></div>

<p>If all goes well, you should receive an email shortly that looks like this:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/16.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 16" /></p>

<p>It’s almost identical to the one you sent using the SendGrid dashboard, except that the subject is not prepended with “Test - ”.</p>

<p>Next, you will learn how to get the Twilio Blog RSS feed, obtain relevant data from XML and blog post HTML pages, and prepare the dynamic data for the email template.</p>

<h2 id="get-the-twilio-blog-rss-feed">Get the Twilio Blog RSS Feed</h2>

<p>To follow the code explanations below, check out the latest code. While still in the project folder, run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git checkout main
</code></pre></div></div>

<p>Alternatively, follow the instructions below to get to the last version of the code:</p>

<p>Under the Services folder, create a new file named TwilioBlogDataProvider.cs and replace its contents with the following:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.Extensions.Options</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">RssFeedDigestEmailer.Cli.Configuration</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">RssFeedDigestEmailer.Cli.Services.Interfaces</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">RssFeedDigestEmailer.Cli.Services</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">TwilioBlogDataProvider</span> <span class="p">:</span> <span class="n">IDataProvider</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">IRssService</span> <span class="n">_rssService</span><span class="p">;</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">EmailDataSettings</span> <span class="n">_emailDataSettings</span><span class="p">;</span>
    <span class="k">public</span> <span class="nf">TwilioBlogDataProvider</span><span class="p">(</span><span class="n">IRssService</span> <span class="n">rssService</span><span class="p">,</span> <span class="n">IOptions</span><span class="p">&lt;</span><span class="n">EmailDataSettings</span><span class="p">&gt;</span> <span class="n">emailDataSettings</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_rssService</span> <span class="p">=</span> <span class="n">rssService</span><span class="p">;</span>
        <span class="n">_emailDataSettings</span> <span class="p">=</span> <span class="n">emailDataSettings</span><span class="p">.</span><span class="n">Value</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">object</span><span class="p">&gt;</span> <span class="nf">GetEmailData</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">blogInfo</span> <span class="p">=</span> <span class="k">await</span> <span class="n">_rssService</span><span class="p">.</span><span class="nf">GetBlogInfo</span><span class="p">();</span>
        <span class="k">return</span> <span class="k">new</span>
        <span class="p">{</span>
            <span class="n">recipientName</span> <span class="p">=</span> <span class="n">_emailDataSettings</span><span class="p">.</span><span class="n">RecipientName</span><span class="p">,</span>
            <span class="n">subject</span> <span class="p">=</span> <span class="s">$"</span><span class="p">{</span><span class="n">_emailDataSettings</span><span class="p">.</span><span class="n">SubjectPrefix</span><span class="p">}</span><span class="s"> - </span><span class="p">{</span><span class="nf">FormatDate</span><span class="p">(</span><span class="n">DateTime</span><span class="p">.</span><span class="n">Today</span><span class="p">)}</span><span class="s">"</span><span class="p">,</span>
            <span class="n">lastBuildDate</span> <span class="p">=</span> <span class="nf">FormatDate</span><span class="p">(</span><span class="n">blogInfo</span><span class="p">.</span><span class="n">LastBuildDate</span><span class="p">,</span> <span class="n">showTime</span><span class="p">:</span> <span class="k">true</span><span class="p">),</span>
            <span class="n">blogPostList</span> <span class="p">=</span> <span class="n">blogInfo</span><span class="p">.</span><span class="n">BlogPosts</span><span class="p">.</span><span class="nf">Select</span><span class="p">(</span><span class="n">b</span> <span class="p">=&gt;</span> <span class="k">new</span>
            <span class="p">{</span>
                <span class="n">title</span> <span class="p">=</span> <span class="n">b</span><span class="p">.</span><span class="n">Title</span><span class="p">,</span> 
                <span class="n">link</span> <span class="p">=</span> <span class="n">b</span><span class="p">.</span><span class="n">Link</span><span class="p">,</span> 
                <span class="n">headerImageUrl</span> <span class="p">=</span> <span class="n">b</span><span class="p">.</span><span class="n">HeaderImageUrl</span><span class="p">,</span> 
                <span class="n">description</span> <span class="p">=</span> <span class="n">b</span><span class="p">.</span><span class="n">Description</span><span class="p">,</span>
                <span class="n">author</span> <span class="p">=</span> <span class="n">b</span><span class="p">.</span><span class="n">Author</span><span class="p">,</span>
                <span class="n">publishDate</span> <span class="p">=</span> <span class="nf">FormatDate</span><span class="p">(</span><span class="n">b</span><span class="p">.</span><span class="n">PublishDate</span><span class="p">),</span>
                <span class="n">categories</span> <span class="p">=</span> <span class="kt">string</span><span class="p">.</span><span class="nf">Join</span><span class="p">(</span><span class="s">"; "</span><span class="p">,</span> <span class="n">b</span><span class="p">.</span><span class="n">Categories</span><span class="p">)</span> 
            <span class="p">})</span>
        <span class="p">};</span>
        <span class="kt">string</span> <span class="nf">FormatDate</span><span class="p">(</span><span class="n">DateTime</span> <span class="n">date</span><span class="p">,</span> <span class="kt">bool</span> <span class="n">showTime</span> <span class="p">=</span> <span class="k">false</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">return</span> <span class="p">(</span><span class="n">showTime</span><span class="p">)</span> <span class="p">?</span> <span class="n">date</span><span class="p">.</span><span class="nf">ToString</span><span class="p">(</span><span class="s">"dd MMMM yyyy, HH:mm"</span><span class="p">)</span> <span class="p">:</span> <span class="n">date</span><span class="p">.</span><span class="nf">ToString</span><span class="p">(</span><span class="s">"dd MMMM yyyy"</span><span class="p">);</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Under Services/Interfaces, create IRssService.cs and paste the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">RssFeedDigestEmailer.Cli.Models</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">RssFeedDigestEmailer.Cli.Services.Interfaces</span><span class="p">;</span>
<span class="k">public</span> <span class="k">interface</span> <span class="nc">IRssService</span>
<span class="p">{</span>
    <span class="n">Task</span><span class="p">&lt;</span><span class="n">BlogInfo</span><span class="p">&gt;</span> <span class="nf">GetBlogInfo</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Under Services, create RssService.cs and paste the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">System.Xml</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.Extensions.Options</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">RssFeedDigestEmailer.Cli.Configuration</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">RssFeedDigestEmailer.Cli.Models</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">RssFeedDigestEmailer.Cli.Services.Interfaces</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">RssFeedDigestEmailer.Cli.Services</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">RssService</span> <span class="p">:</span> <span class="n">IRssService</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">IHtmlService</span> <span class="n">_htmlService</span><span class="p">;</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">IHttpClientFactory</span> <span class="n">_httpClientFactory</span><span class="p">;</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">RssSettings</span> <span class="n">_rssSettings</span><span class="p">;</span>
    <span class="k">public</span> <span class="nf">RssService</span><span class="p">(</span><span class="n">IHtmlService</span> <span class="n">htmlService</span><span class="p">,</span> <span class="n">IHttpClientFactory</span> <span class="n">httpClientFactory</span><span class="p">,</span> <span class="n">IOptions</span><span class="p">&lt;</span><span class="n">RssSettings</span><span class="p">&gt;</span> <span class="n">rssSettings</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_htmlService</span> <span class="p">=</span> <span class="n">htmlService</span><span class="p">;</span>
        <span class="n">_rssSettings</span> <span class="p">=</span> <span class="n">rssSettings</span><span class="p">.</span><span class="n">Value</span><span class="p">;</span>
        <span class="n">_httpClientFactory</span> <span class="p">=</span> <span class="n">httpClientFactory</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">BlogInfo</span><span class="p">&gt;</span> <span class="nf">GetBlogInfo</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">httpClient</span> <span class="p">=</span> <span class="n">_httpClientFactory</span><span class="p">.</span><span class="nf">CreateClient</span><span class="p">(</span><span class="s">"RssServiceHttpClient"</span><span class="p">);</span>
        <span class="k">using</span> <span class="p">(</span><span class="n">HttpResponseMessage</span> <span class="n">response</span> <span class="p">=</span> <span class="k">await</span> <span class="n">httpClient</span><span class="p">.</span><span class="nf">GetAsync</span><span class="p">(</span><span class="n">_rssSettings</span><span class="p">.</span><span class="n">FeedUrl</span><span class="p">))</span>
        <span class="k">using</span> <span class="p">(</span><span class="kt">var</span> <span class="n">rawRssFeedStream</span> <span class="p">=</span> <span class="k">await</span> <span class="n">response</span><span class="p">.</span><span class="n">Content</span><span class="p">.</span><span class="nf">ReadAsStreamAsync</span><span class="p">())</span>
        <span class="p">{</span> 
            <span class="kt">var</span> <span class="n">xmlDocument</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">XmlDocument</span><span class="p">();</span>
            <span class="n">xmlDocument</span><span class="p">.</span><span class="nf">Load</span><span class="p">(</span><span class="n">rawRssFeedStream</span><span class="p">);</span>
            <span class="kt">var</span> <span class="n">blogInfo</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">BlogInfo</span><span class="p">();</span>
            <span class="n">XmlNode</span> <span class="n">lastBuildDateNode</span> <span class="p">=</span> <span class="n">xmlDocument</span><span class="p">.</span><span class="nf">SelectSingleNode</span><span class="p">(</span><span class="s">"/rss/channel/lastBuildDate"</span><span class="p">);</span>
            <span class="n">blogInfo</span><span class="p">.</span><span class="n">LastBuildDate</span> <span class="p">=</span> <span class="n">DateTime</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="n">lastBuildDateNode</span><span class="p">.</span><span class="n">InnerText</span><span class="p">);</span>
            <span class="n">XmlNodeList</span> <span class="n">itemNodeList</span> <span class="p">=</span> <span class="n">xmlDocument</span><span class="p">.</span><span class="nf">SelectNodes</span><span class="p">(</span><span class="s">"/rss/channel/item"</span><span class="p">);</span>
            <span class="n">XmlNamespaceManager</span> <span class="n">xmlNamespaceManager</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">XmlNamespaceManager</span><span class="p">(</span><span class="n">xmlDocument</span><span class="p">.</span><span class="n">NameTable</span><span class="p">);</span>
            <span class="n">xmlNamespaceManager</span><span class="p">.</span><span class="nf">AddNamespace</span><span class="p">(</span><span class="s">"dc"</span><span class="p">,</span> <span class="s">"http://purl.org/dc/elements/1.1/"</span><span class="p">);</span>
            <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="p">=</span> <span class="m">0</span><span class="p">;</span> <span class="n">i</span> <span class="p">&lt;</span> <span class="n">itemNodeList</span><span class="p">.</span><span class="n">Count</span><span class="p">;</span> <span class="n">i</span><span class="p">++)</span>
            <span class="p">{</span>
                <span class="n">XmlNode</span> <span class="n">titleNode</span> <span class="p">=</span> <span class="n">itemNodeList</span><span class="p">[</span><span class="n">i</span><span class="p">].</span><span class="nf">SelectSingleNode</span><span class="p">(</span><span class="s">"title"</span><span class="p">);</span>
                <span class="n">XmlNode</span> <span class="n">linkNode</span> <span class="p">=</span> <span class="n">itemNodeList</span><span class="p">[</span><span class="n">i</span><span class="p">].</span><span class="nf">SelectSingleNode</span><span class="p">(</span><span class="s">"link"</span><span class="p">);</span>
                <span class="n">XmlNodeList</span> <span class="n">categoryNodes</span> <span class="p">=</span> <span class="n">itemNodeList</span><span class="p">[</span><span class="n">i</span><span class="p">].</span><span class="nf">SelectNodes</span><span class="p">(</span><span class="s">"category"</span><span class="p">);</span>
                <span class="n">XmlNode</span> <span class="n">descriptionNode</span> <span class="p">=</span> <span class="n">itemNodeList</span><span class="p">[</span><span class="n">i</span><span class="p">].</span><span class="nf">SelectSingleNode</span><span class="p">(</span><span class="s">"description"</span><span class="p">);</span>
                <span class="n">XmlNode</span> <span class="n">authorNode</span> <span class="p">=</span> <span class="n">itemNodeList</span><span class="p">[</span><span class="n">i</span><span class="p">].</span><span class="nf">SelectSingleNode</span><span class="p">(</span><span class="s">"dc:creator"</span><span class="p">,</span> <span class="n">xmlNamespaceManager</span><span class="p">);</span>
                <span class="kt">var</span> <span class="n">headerImageUrlAndPublishDate</span> <span class="p">=</span> <span class="k">await</span> <span class="n">_htmlService</span><span class="p">.</span><span class="nf">GetHeaderImageUrlAndPostDate</span><span class="p">(</span><span class="n">linkNode</span><span class="p">.</span><span class="n">InnerText</span><span class="p">);</span>
                <span class="kt">var</span> <span class="n">blogPost</span> <span class="p">=</span> <span class="k">new</span> <span class="n">BlogPost</span>
                <span class="p">{</span>
                    <span class="n">Title</span> <span class="p">=</span> <span class="n">titleNode</span><span class="p">.</span><span class="n">InnerText</span><span class="p">,</span>
                    <span class="n">Link</span> <span class="p">=</span> <span class="n">linkNode</span><span class="p">.</span><span class="n">InnerText</span><span class="p">,</span>
                    <span class="n">Categories</span> <span class="p">=</span> <span class="n">categoryNodes</span><span class="p">.</span><span class="n">Cast</span><span class="p">&lt;</span><span class="n">XmlNode</span><span class="p">&gt;().</span><span class="nf">Select</span><span class="p">(</span><span class="n">node</span> <span class="p">=&gt;</span> <span class="n">node</span><span class="p">.</span><span class="n">InnerText</span><span class="p">).</span><span class="nf">ToList</span><span class="p">(),</span>
                    <span class="n">Author</span> <span class="p">=</span> <span class="n">authorNode</span><span class="p">.</span><span class="n">InnerText</span><span class="p">,</span>
                    <span class="n">HeaderImageUrl</span> <span class="p">=</span> <span class="n">headerImageUrlAndPublishDate</span><span class="p">.</span><span class="n">Item1</span><span class="p">,</span>
                    <span class="n">Description</span> <span class="p">=</span> <span class="k">await</span> <span class="n">_htmlService</span><span class="p">.</span><span class="nf">GetPostIntroduction</span><span class="p">(</span><span class="n">descriptionNode</span><span class="p">.</span><span class="n">InnerText</span><span class="p">),</span>
                    <span class="n">PublishDate</span> <span class="p">=</span> <span class="n">DateTime</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="n">headerImageUrlAndPublishDate</span><span class="p">.</span><span class="n">Item2</span><span class="p">)</span>
                <span class="p">};</span>
                <span class="n">blogInfo</span><span class="p">.</span><span class="n">BlogPosts</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="n">blogPost</span><span class="p">);</span>
            <span class="p">}</span>
            <span class="k">return</span> <span class="n">blogInfo</span><span class="p">;</span>   
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Under Services/Interfaces, create IHtmlService.cs and paste the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">RssFeedDigestEmailer.Cli.Services.Interfaces</span><span class="p">;</span>
<span class="k">public</span> <span class="k">interface</span> <span class="nc">IHtmlService</span>
<span class="p">{</span>
    <span class="n">Task</span><span class="p">&lt;(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">string</span><span class="p">)&gt;</span> <span class="nf">GetHeaderImageUrlAndPostDate</span><span class="p">(</span><span class="kt">string</span> <span class="n">blogPostUrl</span><span class="p">);</span>
    <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">GetPostIntroduction</span><span class="p">(</span><span class="kt">string</span> <span class="n">rawPostHtml</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Under Services, create HtmlService.cs and paste the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">AngleSharp</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">AngleSharp.Dom</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">AngleSharp.Html.Parser</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">RssFeedDigestEmailer.Cli.Services.Interfaces</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">RssFeedDigestEmailer.Cli.Services</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">HtmlService</span> <span class="p">:</span> <span class="n">IHtmlService</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">string</span><span class="p">)&gt;</span> <span class="nf">GetHeaderImageUrlAndPostDate</span><span class="p">(</span><span class="kt">string</span> <span class="n">blogPostUrl</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">config</span> <span class="p">=</span> <span class="n">AngleSharp</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="n">Default</span><span class="p">.</span><span class="nf">WithDefaultLoader</span><span class="p">();</span>
        <span class="kt">var</span> <span class="n">address</span> <span class="p">=</span> <span class="n">blogPostUrl</span><span class="p">;</span>
        <span class="kt">var</span> <span class="n">context</span> <span class="p">=</span> <span class="n">BrowsingContext</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="n">config</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">document</span> <span class="p">=</span> <span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="nf">OpenAsync</span><span class="p">(</span><span class="n">address</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">cellSelector</span> <span class="p">=</span> <span class="s">"#header_image &gt; img"</span><span class="p">;</span>
        <span class="kt">var</span> <span class="n">cell</span> <span class="p">=</span> <span class="n">document</span><span class="p">.</span><span class="nf">QuerySelector</span><span class="p">(</span><span class="n">cellSelector</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">headerImgSrc</span> <span class="p">=</span> <span class="n">cell</span><span class="p">.</span><span class="n">Attributes</span><span class="p">.</span><span class="nf">GetNamedItem</span><span class="p">(</span><span class="s">"src"</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">publishDateCellSelector</span> <span class="p">=</span> <span class="s">"body &gt; main &gt; section &gt; ul &gt; article &gt; header &gt; div &gt; div.article-authors &gt; span"</span><span class="p">;</span>
        <span class="kt">var</span> <span class="n">publishDateCell</span> <span class="p">=</span> <span class="n">document</span><span class="p">.</span><span class="nf">QuerySelector</span><span class="p">(</span><span class="n">publishDateCellSelector</span><span class="p">);</span>
        <span class="k">return</span> <span class="p">(</span><span class="n">headerImgSrc</span><span class="p">?.</span><span class="n">Value</span><span class="p">,</span> <span class="n">publishDateCell</span><span class="p">?.</span><span class="n">InnerHtml</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">GetPostIntroduction</span><span class="p">(</span><span class="kt">string</span> <span class="n">rawPostHtml</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">parser</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">HtmlParser</span><span class="p">();</span>
        <span class="kt">var</span> <span class="n">document</span> <span class="p">=</span> <span class="n">parser</span><span class="p">.</span><span class="nf">ParseDocument</span><span class="p">(</span><span class="n">rawPostHtml</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">cellSelector</span> <span class="p">=</span> <span class="s">"div:nth-child(1) &gt; p:nth-child(1)"</span><span class="p">;</span>
        <span class="kt">var</span> <span class="n">cell</span> <span class="p">=</span> <span class="n">document</span><span class="p">.</span><span class="nf">QuerySelector</span><span class="p">(</span><span class="n">cellSelector</span><span class="p">);</span>
        <span class="k">return</span> <span class="n">cell</span><span class="p">?.</span><span class="n">InnerHtml</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Under Configuration, create EmailDataSettings.cs and paste the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">RssFeedDigestEmailer.Cli.Configuration</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">EmailDataSettings</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="n">RecipientName</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="n">SubjectPrefix</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Under Configuration, create RssSettings.cs and paste the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">RssFeedDigestEmailer.Cli.Configuration</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">RssSettings</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="n">FeedUrl</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Update Program.cs as below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">System.Reflection</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.Extensions.Configuration</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.Extensions.DependencyInjection</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.Extensions.Hosting</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">RssFeedDigestEmailer.Cli.Configuration</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">RssFeedDigestEmailer.Cli.Services</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">RssFeedDigestEmailer.Cli.Services.Interfaces</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">SendGrid.Extensions.DependencyInjection</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">IHost</span> <span class="n">host</span> <span class="p">=</span> <span class="n">Host</span><span class="p">.</span><span class="nf">CreateDefaultBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">ConfigureHostConfiguration</span><span class="p">(</span><span class="n">config</span> <span class="p">=&gt;</span>
    <span class="p">{</span>
        <span class="n">config</span>
            <span class="p">.</span><span class="nf">AddUserSecrets</span><span class="p">(</span><span class="n">Assembly</span><span class="p">.</span><span class="nf">GetExecutingAssembly</span><span class="p">(),</span> <span class="k">true</span><span class="p">,</span> <span class="k">false</span><span class="p">);</span>
    <span class="p">})</span>
    <span class="p">.</span><span class="nf">ConfigureServices</span><span class="p">((</span><span class="n">hostBuilderContext</span><span class="p">,</span> <span class="n">services</span><span class="p">)</span> <span class="p">=&gt;</span>
    <span class="p">{</span>
        <span class="n">services</span>
            <span class="p">.</span><span class="n">AddTransient</span><span class="p">&lt;</span><span class="n">IRssService</span><span class="p">,</span> <span class="n">RssService</span><span class="p">&gt;()</span>
            <span class="p">.</span><span class="n">AddTransient</span><span class="p">&lt;</span><span class="n">IEmailService</span><span class="p">,</span> <span class="n">EmailService</span><span class="p">&gt;()</span>
            <span class="p">.</span><span class="n">AddTransient</span><span class="p">&lt;</span><span class="n">IHtmlService</span><span class="p">,</span> <span class="n">HtmlService</span><span class="p">&gt;()</span>
            <span class="p">.</span><span class="n">AddTransient</span><span class="p">&lt;</span><span class="n">IDataProvider</span><span class="p">,</span> <span class="n">TwilioBlogDataProvider</span><span class="p">&gt;()</span>
            <span class="p">.</span><span class="nf">AddHttpClient</span><span class="p">(</span><span class="s">"RssServiceHttpClient"</span><span class="p">);</span>
        <span class="n">services</span>
            <span class="p">.</span><span class="nf">AddSendGrid</span><span class="p">(</span><span class="n">options</span> <span class="p">=&gt;</span> <span class="n">options</span><span class="p">.</span><span class="n">ApiKey</span> <span class="p">=</span> <span class="n">hostBuilderContext</span><span class="p">.</span><span class="n">Configuration</span><span class="p">[</span><span class="s">"SendGridSettings:ApiKey"</span><span class="p">]);</span>
        <span class="n">services</span>   
            <span class="p">.</span><span class="n">Configure</span><span class="p">&lt;</span><span class="n">EmailSettings</span><span class="p">&gt;(</span><span class="n">hostBuilderContext</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetSection</span><span class="p">(</span><span class="s">"EmailSettings"</span><span class="p">))</span>
            <span class="p">.</span><span class="n">Configure</span><span class="p">&lt;</span><span class="n">RssSettings</span><span class="p">&gt;(</span><span class="n">hostBuilderContext</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetSection</span><span class="p">(</span><span class="s">"RssSettings"</span><span class="p">))</span>
            <span class="p">.</span><span class="n">Configure</span><span class="p">&lt;</span><span class="n">EmailDataSettings</span><span class="p">&gt;(</span><span class="n">hostBuilderContext</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetSection</span><span class="p">(</span><span class="s">"EmailDataSettings"</span><span class="p">));</span>
    <span class="p">})</span>
    <span class="p">.</span><span class="nf">Build</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">emailService</span> <span class="p">=</span> <span class="p">(</span><span class="n">EmailService</span><span class="p">)</span> <span class="n">ActivatorUtilities</span><span class="p">.</span><span class="nf">CreateInstance</span><span class="p">(</span><span class="n">host</span><span class="p">.</span><span class="n">Services</span><span class="p">,</span> <span class="k">typeof</span><span class="p">(</span><span class="n">EmailService</span><span class="p">));</span>
<span class="k">await</span> <span class="n">emailService</span><span class="p">.</span><span class="nf">SendTemplatedEmail</span><span class="p">();</span>
</code></pre></div></div>

<p>Update appsettings.json and add the new configuration settings after the emailSettings section:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="w">    </span><span class="nl">"rssSettings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"feedUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://www.twilio.com/blog/feed"</span><span class="w">
    </span><span class="p">}</span><span class="err">,</span><span class="w">
    </span><span class="nl">"emailDataSettings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"recipientName"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
        </span><span class="nl">"subjectPrefix"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="what-is-rss">What is RSS?</h3>

<p><a href="https://en.wikipedia.org/wiki/RSS">RSS</a> (Really Simple Syndication) is an easy way to keep up with news and blogs. It’s an XML-based feed generated by the websites. RSS readers periodically download these feeds and compare them to what they have locally. This way, the user can get notifications for the updates.</p>

<p>In this example, you will only look into downloading and parsing an RSS feed.</p>

<h3 id="downloading-rss-feed">Downloading RSS Feed</h3>

<p>First, you need to find out the address of the RSS feed. These are generally plain XML files hosted on the blog or website. If you know that there is an RSS feed, but you don’t know how to find it, one way to find out is to check the website’s source code. For example, on the Twilio Blog, you can view the source and search for RSS in the code. Next, you should see the link to the feed:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/17.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 17" /></p>

<p>Once you know where to download the feed, the rest is the same as downloading any file from the internet. The project uses the following code snippet to download the RSS feed and create the <code class="language-plaintext highlighter-rouge">XmlDocument</code> by loading the XML stream:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">httpClient</span> <span class="p">=</span> <span class="n">_httpClientFactory</span><span class="p">.</span><span class="nf">CreateClient</span><span class="p">(</span><span class="s">"RssServiceHttpClient"</span><span class="p">);</span>
<span class="k">using</span> <span class="p">(</span><span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">await</span> <span class="n">httpClient</span><span class="p">.</span><span class="nf">GetAsync</span><span class="p">(</span><span class="n">_rssSettings</span><span class="p">.</span><span class="n">FeedUrl</span><span class="p">))</span>
<span class="k">using</span> <span class="p">(</span><span class="kt">var</span> <span class="n">rssFeedStream</span> <span class="p">=</span> <span class="k">await</span> <span class="n">response</span><span class="p">.</span><span class="n">Content</span><span class="p">.</span><span class="nf">ReadAsStreamAsync</span><span class="p">())</span>
<span class="p">{</span> 
    <span class="kt">var</span> <span class="n">xmlDocument</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">XmlDocument</span><span class="p">();</span>
    <span class="n">xmlDocument</span><span class="p">.</span><span class="nf">Load</span><span class="p">(</span><span class="n">rssFeedStream</span><span class="p">);</span>
<span class="err">…</span>
</code></pre></div></div>

<p>The _httpClientFactory variable shown above is injected during the program setup:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">.</span><span class="nf">AddHttpClient</span><span class="p">(</span><span class="s">"RssServiceHttpClient"</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="parsing-xml">Parsing XML</h3>

<p>After you get the raw XML, the next step is to parse and extract the bits you will use in your dynamic template.</p>

<p>You can find <a href="https://validator.w3.org/feed/docs/rss2.html">the full RSS 2.0 specification here</a>. In the example project, you will use the main required elements: <code class="language-plaintext highlighter-rouge">title</code>, <code class="language-plaintext highlighter-rouge">link</code>, and <code class="language-plaintext highlighter-rouge">description</code>.</p>

<p><img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/18.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 18" /></p>

<p>In the example, the built-in <code class="language-plaintext highlighter-rouge">System.XML</code> classes are used to parse the XML. The blog post items are under the <code class="language-plaintext highlighter-rouge">/rss/channel/item</code> path, so you get those elements like this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">XmlNodeList</span> <span class="n">itemNodeList</span> <span class="p">=</span> <span class="n">xmlDocument</span><span class="p">.</span><span class="nf">SelectNodes</span><span class="p">(</span><span class="s">"/rss/channel/item"</span><span class="p">);</span>
</code></pre></div></div>

<p>The next step is to loop through the XML nodes in the <code class="language-plaintext highlighter-rouge">XmlNodeList</code> object which can be accessed by their index in the array:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="p">=</span> <span class="m">0</span><span class="p">;</span> <span class="n">i</span> <span class="p">&lt;</span> <span class="n">itemNodeList</span><span class="p">.</span><span class="n">Count</span><span class="p">;</span> <span class="n">i</span><span class="p">++)</span>
<span class="p">{</span>
    <span class="n">XmlNode</span> <span class="n">titleNode</span> <span class="p">=</span> <span class="n">itemNodeList</span><span class="p">[</span><span class="n">i</span><span class="p">].</span><span class="nf">SelectSingleNode</span><span class="p">(</span><span class="s">"title"</span><span class="p">);</span>
    <span class="n">XmlNode</span> <span class="n">linkNode</span> <span class="p">=</span> <span class="n">itemNodeList</span><span class="p">[</span><span class="n">i</span><span class="p">].</span><span class="nf">SelectSingleNode</span><span class="p">(</span><span class="s">"link"</span><span class="p">);</span>
    <span class="n">XmlNode</span> <span class="n">descriptionNode</span> <span class="p">=</span> <span class="n">itemNodeList</span><span class="p">[</span><span class="n">i</span><span class="p">].</span><span class="nf">SelectSingleNode</span><span class="p">(</span><span class="s">"description"</span><span class="p">);</span>
    <span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Other nodes are parsed and used in the example, but for brevity, the code snippet above only shows the required ones.</p>

<p>One thing to note is parsing the elements with namespaces. In the example project, only the author element has a namespace, and that’s why it’s treated a bit differently. For example, an author element looks like this in the RSS feed:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;dc:creator</span>
        <span class="na">xmlns:dc=</span><span class="s">"http://purl.org/dc/elements/1.1/"</span><span class="nt">&gt;</span>Firstname Lastname
<span class="nt">&lt;/dc:creator&gt;</span>
</code></pre></div></div>

<p>To access this element, first, you need to define an XML namespace:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">XmlNamespaceManager</span> <span class="n">xmlNamespaceManager</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">XmlNamespaceManager</span><span class="p">(</span><span class="n">xmlDocument</span><span class="p">.</span><span class="n">NameTable</span><span class="p">);</span>
<span class="n">xmlNamespaceManager</span><span class="p">.</span><span class="nf">AddNamespace</span><span class="p">(</span><span class="s">"dc"</span><span class="p">,</span> <span class="s">"http://purl.org/dc/elements/1.1/"</span><span class="p">);</span>
</code></pre></div></div>

<p>And in the parsing code, you can access the element like this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">XmlNode</span> <span class="n">authorNode</span> <span class="p">=</span> <span class="n">itemNodeList</span><span class="p">[</span><span class="n">i</span><span class="p">].</span><span class="nf">SelectSingleNode</span><span class="p">(</span><span class="s">"dc:creator"</span><span class="p">,</span> <span class="n">xmlNamespaceManager</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="parsing-html">Parsing HTML</h3>

<p>Initially, I was planning to use only the RSS feed to obtain all the data used in the dynamic template. I also wanted to use the blog header image and published date in the digest email. Unfortunately, those bits of information don’t come in the RSS XML data. That’s why I resorted to HTML parsing. The risk of HTML parsing is that Twilio could change its HTML structure and CSS classes at any point, which would break the code.</p>

<p>!!!info</p>

<p>The HTML parsing is implemented by using the <a href="https://github.com/AngleSharp/AngleSharp">AngleSharp</a> library.</p>

<p>!!!</p>

<p>For every blog post in the feed, the sample project parses the post HTML and selects the header image and the publish date:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">string</span><span class="p">)&gt;</span> <span class="nf">GetHeaderImageUrlAndPostDate</span><span class="p">(</span><span class="kt">string</span> <span class="n">blogPostUrl</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">config</span> <span class="p">=</span> <span class="n">AngleSharp</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="n">Default</span><span class="p">.</span><span class="nf">WithDefaultLoader</span><span class="p">();</span>
    <span class="kt">var</span> <span class="n">address</span> <span class="p">=</span> <span class="n">blogPostUrl</span><span class="p">;</span>
    <span class="kt">var</span> <span class="n">context</span> <span class="p">=</span> <span class="n">BrowsingContext</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="n">config</span><span class="p">);</span>
    <span class="kt">var</span> <span class="n">document</span> <span class="p">=</span> <span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="nf">OpenAsync</span><span class="p">(</span><span class="n">address</span><span class="p">);</span>
    <span class="kt">var</span> <span class="n">cellSelector</span> <span class="p">=</span> <span class="s">"#header_image &gt; img"</span><span class="p">;</span>
    <span class="kt">var</span> <span class="n">cell</span> <span class="p">=</span> <span class="n">document</span><span class="p">.</span><span class="nf">QuerySelector</span><span class="p">(</span><span class="n">cellSelector</span><span class="p">);</span>
    <span class="kt">var</span> <span class="n">headerImgSrc</span> <span class="p">=</span> <span class="n">cell</span><span class="p">.</span><span class="n">Attributes</span><span class="p">.</span><span class="nf">GetNamedItem</span><span class="p">(</span><span class="s">"src"</span><span class="p">);</span>
    <span class="kt">var</span> <span class="n">publishDateCellSelector</span> <span class="p">=</span> <span class="s">"body &gt; main &gt; section &gt; ul &gt; article &gt; header &gt; div &gt; div.article-authors &gt; span"</span><span class="p">;</span>
    <span class="kt">var</span> <span class="n">publishDateCell</span> <span class="p">=</span> <span class="n">document</span><span class="p">.</span><span class="nf">QuerySelector</span><span class="p">(</span><span class="n">publishDateCellSelector</span><span class="p">);</span>
    <span class="k">return</span> <span class="p">(</span><span class="n">headerImgSrc</span><span class="p">?.</span><span class="n">Value</span><span class="p">,</span> <span class="n">publishDateCell</span><span class="p">?.</span><span class="n">InnerHtml</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The important part is finding the CSS selector of the element you’re interested in. This is relatively straightforward with the Developer Tools in your browser.</p>

<p>For example, to get the selector of the header image, open a Twilio blog post. Then right-click on the header image and select Inspect from the context menu. While the element is still selected, right-click again to open the context menu. Click Copy and then Copy selector as shown in the screenshot below:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/19.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 19" /></p>

<p>The selector copied should look like this: <code class="language-plaintext highlighter-rouge">#header_image &gt; img</code></p>

<p>This is the selector used in the project to get a reference to the <code class="language-plaintext highlighter-rouge">img</code> element. Then the src attribute is accessed by the following line:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">headerImgSrc</span> <span class="p">=</span> <span class="n">cell</span><span class="p">.</span><span class="n">Attributes</span><span class="p">.</span><span class="nf">GetNamedItem</span><span class="p">(</span><span class="s">"src"</span><span class="p">);</span>
</code></pre></div></div>

<p>Similarly, you can obtain the CSS selector for the publish date cell and use it to extract the date as a string.</p>

<p>Another use of HTML parsing is to get the introduction part of the blog post. The entire blog post is published in the XML feed, but it would take too much space to put it all in the email, so I decided to pick the first paragraph element (<code class="language-plaintext highlighter-rouge">&lt;p&gt;</code>).</p>

<p>Also, I didn’t want to load the URL for this task as it’s already in the XML data downloaded. So a different approach is used to parse the first paragraph of the blog post, as shown below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">GetPostIntroduction</span><span class="p">(</span><span class="kt">string</span> <span class="n">rawPostHtml</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">parser</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">HtmlParser</span><span class="p">();</span>
    <span class="kt">var</span> <span class="n">document</span> <span class="p">=</span> <span class="n">parser</span><span class="p">.</span><span class="nf">ParseDocument</span><span class="p">(</span><span class="n">rawPostHtml</span><span class="p">);</span>
    <span class="kt">var</span> <span class="n">cellSelector</span> <span class="p">=</span> <span class="s">"div:nth-child(1) &gt; p:nth-child(1)"</span><span class="p">;</span>
    <span class="kt">var</span> <span class="n">cell</span> <span class="p">=</span> <span class="n">document</span><span class="p">.</span><span class="nf">QuerySelector</span><span class="p">(</span><span class="n">cellSelector</span><span class="p">);</span>
    <span class="k">return</span> <span class="n">cell</span><span class="p">?.</span><span class="n">InnerHtml</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>It’s more concise as it doesn’t need to download the HTML over the network and only uses the raw HTML passed in the <code class="language-plaintext highlighter-rouge">rawPostHtml</code> argument.</p>

<h2 id="putting-it-all-together-sending-the-latest-posts-from-the-twilio-blog-via-email">Putting it All Together: Sending the Latest Posts from the Twilio Blog via Email</h2>

<p>Finally, it’s time to reap the rewards of all the preparation work you put in. The example project supports one last command you will look into now: Send email command.</p>

<p>You get all the data to use in the email from the <code class="language-plaintext highlighter-rouge">IDataProvider.GetEmailData</code> implementation. In this final version of the sample application, you will use <code class="language-plaintext highlighter-rouge">TwilioBlogDataProvider</code>, which in turn uses the <code class="language-plaintext highlighter-rouge">RssService</code> by calling the <code class="language-plaintext highlighter-rouge">GetBlogInfo</code> method.</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">blogInfo</span> <span class="p">=</span> <span class="k">await</span> <span class="n">_rssService</span><span class="p">.</span><span class="nf">GetBlogInfo</span><span class="p">();</span>
</code></pre></div></div>

<p>Next, you prepare the dynamic data to be used in the rendered HTML:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">return</span> <span class="k">new</span>
<span class="p">{</span>
    <span class="n">recipientName</span> <span class="p">=</span> <span class="n">_emailDataSettings</span><span class="p">.</span><span class="n">RecipientName</span><span class="p">,</span>
    <span class="n">subject</span> <span class="p">=</span> <span class="s">$"</span><span class="p">{</span><span class="n">_emailDataSettings</span><span class="p">.</span><span class="n">SubjectPrefix</span><span class="p">}</span><span class="s"> - </span><span class="p">{</span><span class="nf">FormatDate</span><span class="p">(</span><span class="n">DateTime</span><span class="p">.</span><span class="n">Today</span><span class="p">)}</span><span class="s">"</span><span class="p">,</span>
    <span class="n">lastBuildDate</span> <span class="p">=</span> <span class="nf">FormatDate</span><span class="p">(</span><span class="n">blogInfo</span><span class="p">.</span><span class="n">LastBuildDate</span><span class="p">,</span> <span class="n">showTime</span><span class="p">:</span> <span class="k">true</span><span class="p">),</span>
    <span class="n">blogPostList</span> <span class="p">=</span> <span class="n">blogInfo</span><span class="p">.</span><span class="n">BlogPosts</span><span class="p">.</span><span class="nf">Select</span><span class="p">(</span><span class="n">b</span> <span class="p">=&gt;</span> <span class="k">new</span>
    <span class="p">{</span>
        <span class="n">title</span> <span class="p">=</span> <span class="n">b</span><span class="p">.</span><span class="n">Title</span><span class="p">,</span> 
        <span class="n">link</span> <span class="p">=</span> <span class="n">b</span><span class="p">.</span><span class="n">Link</span><span class="p">,</span> 
        <span class="n">headerImageUrl</span> <span class="p">=</span> <span class="n">b</span><span class="p">.</span><span class="n">HeaderImageUrl</span><span class="p">,</span> 
        <span class="n">description</span> <span class="p">=</span> <span class="n">b</span><span class="p">.</span><span class="n">Description</span><span class="p">,</span>
        <span class="n">author</span> <span class="p">=</span> <span class="n">b</span><span class="p">.</span><span class="n">Author</span><span class="p">,</span>
        <span class="n">publishDate</span> <span class="p">=</span> <span class="nf">FormatDate</span><span class="p">(</span><span class="n">b</span><span class="p">.</span><span class="n">PublishDate</span><span class="p">),</span>
        <span class="n">categories</span> <span class="p">=</span> <span class="kt">string</span><span class="p">.</span><span class="nf">Join</span><span class="p">(</span><span class="s">"; "</span><span class="p">,</span> <span class="n">b</span><span class="p">.</span><span class="n">Categories</span><span class="p">)</span> 
    <span class="p">})</span>
<span class="p">};</span>
<span class="kt">string</span> <span class="nf">FormatDate</span><span class="p">(</span><span class="n">DateTime</span> <span class="n">date</span><span class="p">,</span> <span class="kt">bool</span> <span class="n">showTime</span> <span class="p">=</span> <span class="k">false</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="p">(</span><span class="n">showTime</span><span class="p">)</span> <span class="p">?</span> <span class="n">date</span><span class="p">.</span><span class="nf">ToString</span><span class="p">(</span><span class="s">"dd MMMM yyyy, HH:mm"</span><span class="p">)</span> <span class="p">:</span> <span class="n">date</span><span class="p">.</span><span class="nf">ToString</span><span class="p">(</span><span class="s">"dd MMMM yyyy"</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The code that sends the email is quite concise:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">dynamicEmailData</span> <span class="p">=</span> <span class="k">await</span> <span class="n">_dataProvider</span><span class="p">.</span><span class="nf">GetEmailData</span><span class="p">();</span>
<span class="kt">var</span> <span class="k">from</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">EmailAddress</span><span class="p">(</span><span class="n">_emailSettings</span><span class="p">.</span><span class="n">SenderEmailAddress</span><span class="p">,</span> <span class="n">_emailSettings</span><span class="p">.</span><span class="n">SenderDisplayName</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">to</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">EmailAddress</span><span class="p">(</span><span class="n">_emailSettings</span><span class="p">.</span><span class="n">RecipientEmailAddress</span><span class="p">,</span> <span class="n">_emailSettings</span><span class="p">.</span><span class="n">RecipientDisplayName</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">msg</span> <span class="p">=</span> <span class="n">MailHelper</span><span class="p">.</span><span class="nf">CreateSingleTemplateEmail</span><span class="p">(</span><span class="k">from</span><span class="p">,</span> <span class="n">to</span><span class="p">,</span> <span class="n">_emailSettings</span><span class="p">.</span><span class="n">TemplateId</span><span class="p">,</span> <span class="n">dynamicEmailData</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">await</span> <span class="n">_sendGridClient</span><span class="p">.</span><span class="nf">SendEmailAsync</span><span class="p">(</span><span class="n">msg</span><span class="p">);</span>
</code></pre></div></div>

<p>To send the email, run the application, and the final email looks like this:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-send-RSS-feed-digest-email-with-CSharp-and-SendGrid-Dynamic-Email-Templates/20.png" alt="How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates - image 20" /></p>

<p>Now you get the same email but with data downloaded from the Twilio Blog RSS feed and individual blog pages and formatted in one nice digest email.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this article, you learned how to create a Dynamic Email Template. You created an HTML email with CSS and used Handlebars templating to render the email with test JSON data. You also learned how to retrieve and parse RSS XML feeds as well as basic HTML parsing.</p>

<p>I hope you found this article helpful and interesting. The <a href="https://github.com/Dev-Power/send-rss-feed-digest-email-with-sendgrid-dynamic-templates">source code</a> is publicly available, so feel free to download and play at will.</p>

<p>If you enjoyed this article, here are a few articles I’d recommend reading about template-based emails:</p>

<ul>
  <li>
    <p><a href="https://www.twilio.com/blog/send-emails-with-csharp-handlebars-templating-and-dynamic-email-templates">Send Emails with C#, Handlebars templating, and Dynamic Email Templates</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/what-is-razor-templating">What is Razor Templating, really?</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/render-emails-using-razor-templating">Render Emails Using Razor Templating</a></p>
  </li>
</ul>
]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[How to get secrets from HashiCorp Vault into .NET configuration with C#]]></title>
    <link href="https://volkanpaksoy.com/archive/2026/08/05/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp/"/>
    <updated>2026-08-05T12:45:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2026/08/05/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp</id>
    <content type="html"><![CDATA[<blockquote>
  <p>This article was originally published on the <a href="https://www.twilio.com/en-us/blog/developers/community/get-secrets-from-hashicorp-vault-into-dotnet-configuration-with-csharp">Twilio Blog</a>.</p>
</blockquote>

<p>Configuration management has always been a challenge for developers. It gets especially tricky when it comes to storing sensitive configuration values such as API keys, tokens, certificates, passwords etc. In this article, you will learn how to use Hashicorp Vault with C# .NET to manage your application’s secrets.</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>You’ll need the following things in this tutorial:</p>

<ul>
  <li>
    <p>A free <a href="https://www.twilio.com/try-twilio">Twilio account</a></p>
  </li>
  <li>
    <p>A <a href="https://support.twilio.com/hc/en-us/articles/223135247-How-to-Search-for-and-Buy-a-Twilio-Phone-Number-from-Console">Twilio Phone Number</a> with SMS capabilities.</p>
  </li>
  <li>
    <p>An OS that supports .NET (Windows/macOS/Linux)</p>
  </li>
  <li>
    <p><a href="https://dotnet.microsoft.com/en-us/download/dotnet/6.0">.NET 6.0 SDK</a> (newer and older versions may work too)</p>
  </li>
  <li>
    <p>A code editor or IDE (Recommended: <a href="https://code.visualstudio.com/Download">Visual Studio Code</a> with <a href="https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp">the C# plugin</a>, <a href="https://visualstudio.microsoft.com/">Visual Studio</a>, or <a href="https://www.jetbrains.com/rider/">JetBrains Rider</a>)</p>
  </li>
  <li>
    <p>Docker Engine (You can install the engine using <a href="https://docs.docker.com/engine/install/">Docker Desktop</a> (Windows, macOS, and Linux), <a href="https://github.com/abiosoft/colima">Colima</a> (macOS and Linux), or manually on any OS.)</p>
  </li>
</ul>

<h2 id="problem-statement">Problem Statement</h2>

<p>Let’s start by implementing a simple application to demonstrate the issue. It’s a simple .NET console application that sends a single SMS via Twilio.</p>

<p>Run the following commands in a terminal to create the project and add the Twilio NuGet package:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir </span>VaultDemo 
<span class="nb">cd </span>VaultDemo
dotnet new console
dotnet add package Twilio
</code></pre></div></div>

<p>Open the project with your IDE and replace the contents of Program.cs with the code below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Twilio</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.Rest.Api.V2010.Account</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.Types</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">accountSid</span> <span class="p">=</span> <span class="s">"{ YOUR TWILIO ACCOUNT SID }"</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">authToken</span> <span class="p">=</span> <span class="s">"{ YOUR TWILIO AUTH TOKEN }"</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">senderPhoneNumber</span> <span class="p">=</span> <span class="s">"{ SENDER PHONE NUMBER }"</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">recipientPhoneNumber</span> <span class="p">=</span> <span class="s">"{ RECIPIENT PHONE NUMBER }"</span><span class="p">;</span>
<span class="n">TwilioClient</span><span class="p">.</span><span class="nf">Init</span><span class="p">(</span><span class="n">accountSid</span><span class="p">,</span> <span class="n">authToken</span><span class="p">);</span>
<span class="n">MessageResource</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span>
    <span class="n">body</span><span class="p">:</span> <span class="s">"Nothing fancy, just a simple SMS."</span><span class="p">,</span>
    <span class="k">from</span><span class="p">:</span> <span class="k">new</span> <span class="nf">PhoneNumber</span><span class="p">(</span><span class="n">senderPhoneNumber</span><span class="p">),</span>
    <span class="n">to</span><span class="p">:</span> <span class="k">new</span> <span class="nf">PhoneNumber</span><span class="p">(</span><span class="n">recipientPhoneNumber</span><span class="p">)</span>
<span class="p">);</span>
</code></pre></div></div>

<p>Replace the placeholders with actual values. To obtain your Twilio Account SID, AuthToken, and Twilio Phone Number, log in to the <a href="https://console.twilio.com/">Twilio Console</a> and copy the values shown in the account info section:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp/01.png" alt="How to get secrets from HashiCorp Vault into .NET configuration with C# - image 1" /></p>

<p>For the recipient phone number, use your actual phone number so that you can receive the SMS and confirm the application is working.</p>

<p>Run the application by running the following command in the terminal:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet run
</code></pre></div></div>

<p>You now have a working, standalone application without any dependencies on external configuration. Hardcoding secrets this way might only work for throw-away code. Even then, it’s ill-advised, and you should never use this approach. If you forget to delete the code, you will expose your secrets in cleartext.</p>

<p>Most applications use a version control system (such as GitHub, GitLab etc). In enterprise, it’s safe to say all code is pushed to a source code repository. If you hardcode your secrets and push your secrets to the version control system, anybody who has access to the code will have access to your secrets too. If you’re working on a public open-source project, you have now exposed your secrets to the entire world.</p>

<p>!!!warning</p>

<p>If you are using Git as your version control system, even if you delete the sensitive data immediately, it will still exist in your git history.</p>

<p>!!!</p>

<p>As a general rule of thumb, putting secrets in your source code is considered to be a terrible practice.</p>

<h3 id="environment-variables">Environment Variables</h3>

<p>A better approach is using environment variables. This way, you can completely separate your code from your config. If you subscribe to the <a href="https://12factor.net/">Twelve-Factor-App</a> philosophy, you can see they describe this approach as “Store config in the environment”. You can read more about it in their <a href="https://12factor.net/config">Config section</a>.</p>

<p>Now, update your application and replace the hardcoded values with the following lines:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">accountSid</span> <span class="p">=</span> <span class="n">Environment</span><span class="p">.</span><span class="nf">GetEnvironmentVariable</span><span class="p">(</span><span class="s">"TWILIO_ACCOUNT_SID"</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">authToken</span> <span class="p">=</span> <span class="n">Environment</span><span class="p">.</span><span class="nf">GetEnvironmentVariable</span><span class="p">(</span><span class="s">"TWILIO_AUTH_TOKEN"</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">senderPhoneNumber</span> <span class="p">=</span> <span class="n">Environment</span><span class="p">.</span><span class="nf">GetEnvironmentVariable</span><span class="p">(</span><span class="s">"SENDER_PHONE_NUMBER"</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">recipientPhoneNumber</span> <span class="p">=</span> <span class="n">Environment</span><span class="p">.</span><span class="nf">GetEnvironmentVariable</span><span class="p">(</span><span class="s">"RECIPIENT_PHONE_NUMBER"</span><span class="p">);</span>
</code></pre></div></div>

<p>For macOS and Linux, set the environment variable like this:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export</span> <span class="o">{</span>KEY<span class="o">}={</span>VALUE<span class="o">}</span>
</code></pre></div></div>

<p>If you’re using PowerShell on Windows or another OS, use this command:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$</span><span class="nn">Env</span><span class="p">:</span><span class="nv">{KEY}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"{VALUE}"</span><span class="w">
</span></code></pre></div></div>

<p>If you’re using CMD on Windows, use this command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">set</span> <span class="s2">"{KEY}={VALUE}"</span>
</code></pre></div></div>

<p>Replace <code class="language-plaintext highlighter-rouge">{KEY}</code> with the name of the configuration/secret key (such as TWILIO_ACCOUNT_SID). Replace <code class="language-plaintext highlighter-rouge">{VALUE}</code> with the value of the configuration/secret (the values you hardcoded in the previous example).</p>

<p>Repeat the above for all 4 configuration values (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER and RECIPIENT_PHONE_NUMBER).</p>

<p>Run the application and confirm it’s still working. Now you have given yourself the opportunity of checking in your code to the version control system as there are no sensitive values in it anymore.</p>

<h3 id="user-secrets">User Secrets</h3>

<p>You can also improve the local development environment security by using .NET user secrets. This way, the secrets are stored in a JSON configuration file in the user profile directory. Since the secrets are persisted, you don’t have to enter them over and over again, whereas with the environment variable you will lose the values if you close the terminal.</p>

<p>To use user secret in the demo application, run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet user-secrets init
</code></pre></div></div>

<p>Now you can add the secrets by running the following commands:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet user-secrets <span class="nb">set</span> <span class="s2">"KEY"</span> <span class="s2">"VALUE"</span>
</code></pre></div></div>

<p>To be able to read these values back, you will need configuration extension NuGet packages from Microsoft. Run the following commands to add those libraries:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Configuration.EnvironmentVariables
dotnet add package Microsoft.Extensions.Configuration.UserSecrets
</code></pre></div></div>

<p>!!!info</p>

<p>You can also use the appSettings.json file, command-line arguments, and more to read the configuration values. You can read more about various configuration options at <a href="https://learn.microsoft.com/en-us/dotnet/core/extensions/configuration">configuration in .NET</a>.</p>

<p>!!!</p>

<p>Update Program.cs as shown below:</p>

<p>```csharp hl_lines=”1”
using Microsoft.Extensions.Configuration;
using Twilio;
using Twilio.Rest.Api.V2010.Account;
using Twilio.Types;
IConfiguration config = new ConfigurationBuilder()
    .AddUserSecrets<Program>(optional: true, reloadOnChange: false)
    .AddEnvironmentVariables()
    .Build();
var accountSid = config["TWILIO_ACCOUNT_SID"];
var authToken = config["TWILIO_AUTH_TOKEN"];
var senderPhoneNumber = config["SENDER_PHONE_NUMBER"];
var recipientPhoneNumber = config["RECIPIENT_PHONE_NUMBER"];
TwilioClient.Init(accountSid, authToken);
MessageResource.Create(
    body: "Nothing fancy, just a simple SMS.",
    from: new PhoneNumber(senderPhoneNumber),
    to: new PhoneNumber(recipientPhoneNumber)
);</Program></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
Run the application, and it should still work. 

This example leverages both user secrets and environment variables. You can still add environment variables to overwrite the values in user secrets. The last key loaded wins, and all the other ones are overwritten. So be careful when specifying multiple providers. The benefit of this approach is now you are not directly reading from environment variables which means you have more control over where you store your configuration.

Now you’re in a more secure position when storing the secrets locally in your development environment. What about deployments, though? When you deploy your application, it will fail because it won’t find the secrets. You can choose to remotely log in to your servers and set the environment variables or user secrets, but it’s impractical and doesn’t scale.  

A better approach is to use a central, secure place to store your configuration so that all instances of your application can easily read those values.

Many services provide this functionality, such as Azure Key Vault, AWS Secrets Manager, and Hashicorp Vault. In this article, you will learn how to set up HashiCorp Vault using Docker and configure your application to get your configuration from it.

## Set Up Hashicorp Vault

Vault is an open-source project and can be found on Hashicorp’s [GitHub repository](https://github.com/hashicorp/vault). Since it’s open-source, you have a few alternatives for using Vault:

- Clone/fork the GitHub repository and build it yourself

- Download and run one of the installers created for your platform

- Run it in a Docker container

- Sign up and use their cloud-based solution

First 3 options are self-hosted and completely free. For the hosted solution, you can sign up for free and evaluate it for 30 days. In this article, you’ll run it in a Docker container. 

!!!info

The focus of the article is not setting up an enterprise-grade Vault cluster, which would be too complicated to cover in a single article. You will see how to run it for your development environment in a single Docker container, but the principle is the same. So if you later purchase a cloud-hosted solution, all you have to do is change the vault address. You can find out more about Vault pricing and packaging [here](https://www.hashicorp.com/products/vault/pricing).

!!!

Vault is one of the select few official images, and it’s quite popular on Docker Hub:

![How to get secrets from HashiCorp Vault into .NET configuration with C# - image 2](/images/vpblogimg/2026/08/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp/02.png)

Run the following command to pull the latest Vault image and run Vault in a container:

```bash
docker run -d -p 8200:8200 --cap-add=IPC_LOCK --name=dev-vault vault
</code></pre></div></div>

<p>In the command above <code class="language-plaintext highlighter-rouge">-d</code> flag indicates to run it in the background. <code class="language-plaintext highlighter-rouge">--cap-add=IPC_LOCK</code> prevents sensitive values from being swapped to disk. As the name of the container implies, this is for development only. Everything runs in memory and all the secrets will disappear if you stop the container.</p>

<p>Now, open a browser and go to <a href="http://localhost:8200">http://localhost:8200</a>. You should see a sign-in screen like this:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp/03.png" alt="How to get secrets from HashiCorp Vault into .NET configuration with C# - image 3" /></p>

<p>To get your token, first, find the container id by running the command below:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker ps | <span class="nb">grep </span>vault
</code></pre></div></div>

<p>You should see something like this:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp/04.png" alt="How to get secrets from HashiCorp Vault into .NET configuration with C# - image 4" /></p>

<p>The first value you see is the container id (in this example it’s 892327726ea3)</p>

<p>Copy that value and run the command below by replacing <code class="language-plaintext highlighter-rouge">{ YOUR CONTAINER ID }</code> with the value you copied:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker logs { YOUR CONTAINER ID }
</code></pre></div></div>

<p>At the end of the logs you should see your root token:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp/05.png" alt="How to get secrets from HashiCorp Vault into .NET configuration with C# - image 5" /></p>

<p>Copy your root token and use it in the sign-in screen.</p>

<p>!!!info</p>

<p>Vault server starts in a sealed state, meaning it doesn’t know how to decrypt the data. Unsealing is the process of obtaining the plaintext root key necessary to read the decryption key to decrypt the data, allowing access to the Vault. You can read more about <a href="https://developer.hashicorp.com/vault/docs/concepts/seal">sealing and unsealing here</a>.</p>

<p>!!!</p>

<p>You should see the default secrets engines:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp/06.png" alt="How to get secrets from HashiCorp Vault into .NET configuration with C# - image 6" /></p>

<p>Cubbyhole and key/value secret engines are enabled by default (and they cannot be disabled). The cubbyhole secrets engine is used to store arbitrary secrets. Paths are scoped per token, and no token can access another token’s cubbyhole. In this article, you will use Key/Value secret engine, which is a generic</p>

<p>secret engine.</p>

<p>Click secret to view the existing secrets (which are none at the moment). You should see a screen like this:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp/07.png" alt="How to get secrets from HashiCorp Vault into .NET configuration with C# - image 7" /></p>

<p>Click the Create secret button.</p>

<p>Set the path to your secret as twilioapp. Add your config values as you did in the previous examples. Your screen should look like this:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp/08.png" alt="How to get secrets from HashiCorp Vault into .NET configuration with C# - image 8" /></p>

<p>Click the Save button. Now you should see the values saved as Version 1 of your configuration:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp/09.png" alt="How to get secrets from HashiCorp Vault into .NET configuration with C# - image 9" /></p>

<p>Now that your secrets are Vault, it’s time to modify the application to read these values.</p>

<h2 id="using-vault-c-client">Using Vault C# Client</h2>

<p>To access Vault with C#, you are going to use a library called <a href="https://github.com/rajanadar/VaultSharp">VaultSharp</a>. Run the following command to add the NuGet package to your project:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package VaultSharp
</code></pre></div></div>

<p>Set the user secret VAULT_ADDR to http://127.0.0.1:8200.</p>

<p>Set the VAULT_TOKEN user secret to your root token.</p>

<p>!!!warning</p>

<p>In production, it’s more likely your operations team will provide you with a role that has access to the secrets your application needs, so you won’t have to use tokens this way. This is just for demonstration purposes.</p>

<p>!!!</p>

<p>Update Program.cs as shown below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.Extensions.Configuration</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.Rest.Api.V2010.Account</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.Types</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">VaultSharp</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">VaultSharp.V1.AuthMethods</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">VaultSharp.V1.AuthMethods.Token</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">VaultSharp.V1.Commons</span><span class="p">;</span>
<span class="n">IConfigurationBuilder</span> <span class="n">configBuilder</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">ConfigurationBuilder</span><span class="p">()</span>
    <span class="p">.</span><span class="n">AddUserSecrets</span><span class="p">&lt;</span><span class="n">Program</span><span class="p">&gt;(</span><span class="n">optional</span><span class="p">:</span> <span class="k">true</span><span class="p">,</span> <span class="n">reloadOnChange</span><span class="p">:</span> <span class="k">false</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">AddEnvironmentVariables</span><span class="p">();</span>
<span class="n">IConfiguration</span> <span class="n">config</span> <span class="p">=</span> <span class="n">configBuilder</span><span class="p">.</span><span class="nf">Build</span><span class="p">();</span>
<span class="n">IAuthMethodInfo</span> <span class="n">authMethod</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">TokenAuthMethodInfo</span><span class="p">(</span><span class="n">config</span><span class="p">[</span><span class="s">"VAULT_TOKEN"</span><span class="p">]);</span>
<span class="kt">var</span> <span class="n">vaultClientSettings</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">VaultClientSettings</span><span class="p">(</span><span class="n">config</span><span class="p">[</span><span class="s">"VAULT_ADDR"</span><span class="p">],</span> <span class="n">authMethod</span><span class="p">);</span>
<span class="n">IVaultClient</span> <span class="n">vaultClient</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">VaultClient</span><span class="p">(</span><span class="n">vaultClientSettings</span><span class="p">);</span>
<span class="n">Secret</span><span class="p">&lt;</span><span class="n">SecretData</span><span class="p">&gt;</span> <span class="n">kv2Secret</span> <span class="p">=</span> <span class="k">await</span> <span class="n">vaultClient</span><span class="p">.</span><span class="n">V1</span><span class="p">.</span><span class="n">Secrets</span><span class="p">.</span><span class="n">KeyValue</span><span class="p">.</span><span class="n">V2</span>
    <span class="p">.</span><span class="nf">ReadSecretAsync</span><span class="p">(</span><span class="n">path</span><span class="p">:</span> <span class="s">"twilioapp"</span><span class="p">,</span> <span class="n">mountPoint</span><span class="p">:</span> <span class="s">"secret"</span><span class="p">);</span>
<span class="n">configBuilder</span><span class="p">.</span><span class="nf">AddInMemoryCollection</span><span class="p">(</span><span class="n">kv2Secret</span><span class="p">.</span><span class="n">Data</span><span class="p">.</span><span class="n">Data</span><span class="p">.</span><span class="nf">ToDictionary</span><span class="p">(</span><span class="n">kv</span> <span class="p">=&gt;</span> <span class="n">kv</span><span class="p">.</span><span class="n">Key</span><span class="p">,</span> <span class="n">kv</span> <span class="p">=&gt;</span> <span class="n">kv</span><span class="p">.</span><span class="n">Value</span><span class="p">.</span><span class="nf">ToString</span><span class="p">()));</span>
<span class="n">config</span> <span class="p">=</span> <span class="n">configBuilder</span><span class="p">.</span><span class="nf">Build</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">accountSid</span> <span class="p">=</span> <span class="n">config</span><span class="p">[</span><span class="s">"TWILIO_ACCOUNT_SID"</span><span class="p">];</span>
<span class="kt">var</span> <span class="n">authToken</span> <span class="p">=</span> <span class="n">config</span><span class="p">[</span><span class="s">"TWILIO_AUTH_TOKEN"</span><span class="p">];</span>
<span class="kt">var</span> <span class="n">senderPhoneNumber</span> <span class="p">=</span> <span class="n">config</span><span class="p">[</span><span class="s">"SENDER_PHONE_NUMBER"</span><span class="p">];</span>
<span class="kt">var</span> <span class="n">recipientPhoneNumber</span> <span class="p">=</span> <span class="n">config</span><span class="p">[</span><span class="s">"RECIPIENT_PHONE_NUMBER"</span><span class="p">];</span>
<span class="n">TwilioClient</span><span class="p">.</span><span class="nf">Init</span><span class="p">(</span><span class="n">accountSid</span><span class="p">,</span> <span class="n">authToken</span><span class="p">);</span>
<span class="n">MessageResource</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span>
    <span class="n">body</span><span class="p">:</span> <span class="s">"Nothing fancy, just a simple SMS."</span><span class="p">,</span>
    <span class="k">from</span><span class="p">:</span> <span class="k">new</span> <span class="nf">PhoneNumber</span><span class="p">(</span><span class="n">senderPhoneNumber</span><span class="p">),</span>
    <span class="n">to</span><span class="p">:</span> <span class="k">new</span> <span class="nf">PhoneNumber</span><span class="p">(</span><span class="n">recipientPhoneNumber</span><span class="p">)</span>
<span class="p">);</span>
</code></pre></div></div>

<p>Run the application again, and you should now be able to get the secrets from your Vault instance.</p>

<p>The implementation above first gets the user secrets to be able to access Vault. Then, reads the secrets from Vault and adds them back to the .NET configuration so that all configuration values can be managed in one place. To make it more reusable, you can refactor it to use an extension method.</p>

<p>!!!info</p>

<p>If you want to implement a configuration provider to retrieve information from Vault, I recommend reading <a href="https://developer.hashicorp.com/vault/tutorials/app-integration/dotnet-httpclient">this article</a> from Hashicorp.</p>

<p>!!!</p>

<p>Vault supports various authentication methods such as app role, AWS auth, Azure auth, etc. Since my focus is on the programming side, I used the basic token authentication method. You can find out more about the other auth methods on the library’s <a href="https://github.com/rajanadar/VaultSharp">GitHub repo</a>.</p>

<p>Now stop the container by running:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker stop <span class="o">{</span> YOUR CONTAINER ID <span class="o">}</span>
</code></pre></div></div>

<p>Run the application, and it will get an error as the Vault is not running anymore. Start the same container again by running</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker start <span class="o">{</span> YOUR CONTAINER ID <span class="o">}</span>
</code></pre></div></div>

<p>Run your application again, and this time it will fail due to the following error: Unhandled exception. VaultSharp.Core.VaultApiException: {“errors”:[“permission denied”]}</p>

<p>If you check the logs of the container again, you will see the root token is different. Copy that one and sign in to your Vault via UI again, and you will see that your previous secrets are gone now. This is, as discussed before, because we didn’t provide a persistence engine, and everything was kept in memory. In the next section, you will learn how to fix this issue.</p>

<h2 id="persisting-secrets">Persisting Secrets</h2>

<p>When you run Hashicorp Vault in dev mode, everything is stored in-memory, and the web UI is enabled automatically. To persist secrets, you need to run Vault in server mode.</p>

<p>Vault has an extendable model and supports various storage providers for persisting secrets. You can use the file system, an RDBMS such as MySQL or MSSQL, a NoSQL database such as CouchDB or Amazon DynamoDB, or even a cloud-based storage service such as Google Cloud Storage or Amazon S3. You can find the full list of supported providers <a href="https://developer.hashicorp.com/vault/docs/configuration/storage">here</a>. All the data will be encrypted at rest (as well as in transit), so even if a 3rd party gains access to the stored secrets, they wouldn’t be able read them. In this article, you will use the local file system to persist your secrets.</p>

<p>If you created a container in the previous section, stop and delete it by running the following commands:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker stop <span class="o">{</span> YOUR CONTAINER ID <span class="o">}</span>
docker <span class="nb">rm</span> <span class="o">{</span> YOUR CONTAINER ID <span class="o">}</span>
</code></pre></div></div>

<p>Then, create a folder to store the Vault configuration. It can be placed anywhere you like on your filesystem. The following example uses /dev/vault/config under your user’s home directory.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> <span class="nv">$HOME</span>/dev/vault/config
</code></pre></div></div>

<p>Create a file named config.hcl under that folder and update its contents as shown below:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ui = true
disable_mlock = true
storage "file" {
  path = "/vault/file"
}
listener "tcp" {
  address = "0.0.0.0:8200"
  tls_disable = "true"
}
api_addr = "http://127.0.0.1:8200"
</code></pre></div></div>

<p>Then, run the following command to create the Vault instance:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run <span class="nt">-d</span> <span class="nt">-p</span> 8200:8200 <span class="nt">--volume</span> <span class="nv">$HOME</span>/dev/vault:/vault vault server
</code></pre></div></div>

<p>Open a browser tab and go to <a href="http://127.0.0.1:8200">http://127.0.0.1:8200</a>.</p>

<p>!!!warning</p>

<p>Keep in mind that this article is not meant to teach you how to install a production-grade Vault server, as it requires high-security, high-availability, backups, monitoring, etc. This is still meant to be used for local development.</p>

<p>!!!</p>

<p>This time, you will see a more involved setup since you are now running Vault in server mode: 
<img src="/images/vpblogimg/2026/08/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp/10.png" alt="How to get secrets from HashiCorp Vault into .NET configuration with C# - image 10" /></p>

<p>Enter 1 in both key shares and key threshold fields.</p>

<p>You should see a successful initialization screen:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp/11.png" alt="How to get secrets from HashiCorp Vault into .NET configuration with C# - image 11" /></p>

<p>Click the Download keys button and get a copy of your keys. Then click the Continue to Unseal button to proceed.</p>

<p>You will then be prompted your Unseal key:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp/12.png" alt="How to get secrets from HashiCorp Vault into .NET configuration with C# - image 12" /></p>

<p>Open the JSON file you just downloaded which looks like this:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp/13.png" alt="How to get secrets from HashiCorp Vault into .NET configuration with C# - image 13" /></p>

<p>Copy the value of the keys property (this is an array but since you requested 1 key share, it only has 1 element.</p>

<p>Click the Unseal button.</p>

<p>Now you should be redirected to the sign-in page. Copy your root token from the JSON and use it to log in.</p>

<p><img src="/images/vpblogimg/2026/08/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp/14.png" alt="How to get secrets from HashiCorp Vault into .NET configuration with C# - image 14" /></p>

<p>You must have noticed it looks different from the dev mode. There is no secret engine called secret. To fix this, click Enable new engine.</p>

<p>Select KV and click Next.</p>

<p><img src="/images/vpblogimg/2026/08/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp/15.png" alt="How to get secrets from HashiCorp Vault into .NET configuration with C# - image 15" /></p>

<p>Enter “secret” in the Path field just to match the previous example and click Enable Engine.</p>

<p><img src="/images/vpblogimg/2026/08/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp/16.png" alt="How to get secrets from HashiCorp Vault into .NET configuration with C# - image 16" /></p>

<p>Now click the Create secret button as you did in the previous section and create secrets.</p>

<p>Update your new Vault token by running the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet user-secrets <span class="nb">set </span>VAULT_TOKEN <span class="o">{</span> YOUR NEW ROOT KEY <span class="o">}</span>
</code></pre></div></div>

<p>Replace <code class="language-plaintext highlighter-rouge">{ YOUR NEW ROOT KEY }</code> with the value you copied from the file you downloaded.</p>

<p>Run your application again and you should receive an SMS as you did before.</p>

<p>Here’s the difference though:</p>

<p>Open a terminal and find the container id by running</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker ps | <span class="nb">grep </span>vault
</code></pre></div></div>

<p>Now run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker restart <span class="o">{</span> YOUR CONTAINER ID <span class="o">}</span>
</code></pre></div></div>

<p>Replace ` { YOUR CONTAINER ID }` with the id you noted from the previous command.</p>

<p>Check the container logs by running</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker logs <span class="o">{</span> YOUR CONTAINER ID <span class="o">}</span>
</code></pre></div></div>

<p>If you recall, the last time you did this you saw an error. This time you should see something like this:</p>

<p><img src="/images/vpblogimg/2026/08/How-to-get-secrets-from-HashiCorp-Vault-into-dotNET-configuration-with-CSharp/17.png" alt="How to get secrets from HashiCorp Vault into .NET configuration with C# - image 17" /></p>

<p>So the Vault server is still running.</p>

<p>Go back to the Web UI, and it will ask you for the unseal key again. After you enter the unseal key and the root token, you can see the secrets you entered have persisted successfully.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this article, I wanted to demonstrate the necessity and benefit of having a centralized, secure system to manage application secrets and configuration. You started from the very primitive hard-coding secrets to code approach to using an external service which is highly reputable and used by many top-level companies all around the world. Granted, your Vault installation is for development only, but the underlying principles still apply to production. I hope you found this article helpful. If you’d like to keep learning about .NET configuration and containerizing applications, I recommend taking a look at these articles:</p>

<ul>
  <li>
    <p><a href="https://www.twilio.com/blog/better-configuration-csharp-dotnet-for-twilio">How to better configure C# and .NET applications for Twilio</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/provide-default-configuration-to-dotnet-applications">Provide default configuration to your .NET applications</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/containerize-your-sql-server-with-docker-and-aspnet-core-with-ef-core">Dockerize your SQL Server and use it in ASP.NET Core with Entity Framework Core</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/containerize-your-aspdotnet-core-application-and-sql-server-with-docker">How to containerize your ASP.NET Core application and SQL Server with Docker</a></p>
  </li>
</ul>
]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[Get notified of new magazine issues using web scraping and SMS with C# .NET]]></title>
    <link href="https://volkanpaksoy.com/archive/2026/08/05/Get-notified-of-new-magazine-issues-using-web-scraping-and-SMS-with-CSharp-dotNET/"/>
    <updated>2026-08-05T12:40:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2026/08/05/Get-notified-of-new-magazine-issues-using-web-scraping-and-SMS-with-CSharp-dotNET</id>
    <content type="html"><![CDATA[<blockquote>
  <p>This article was originally published on the <a href="https://www.twilio.com/en-us/blog/get-notified-of-new-magazine-issues-using-web-scraping-and-sms-with-csharp-dotnet">Twilio Blog</a>.</p>
</blockquote>

<p>As a Raspberry PI fan, I like to read <a href="https://magpi.raspberrypi.com/">The MagPi Magazine</a>, which is freely available as PDFs. The problem is I tend to forget to download it manually every month, so I decided to automate the process. If Raspberry Pi is not your thing, you should be able to modify the <a href="https://github.com/Dev-Power/magazine-issue-tracker-with-notifications">demo application</a> to work for any periodical publication that offers free downloads.</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>You’ll need the following things in this tutorial:</p>

<ul>
  <li>
    <p>A free <a href="https://www.twilio.com/try-twilio">Twilio account</a></p>
  </li>
  <li>
    <p>A <a href="https://support.twilio.com/hc/en-us/articles/223135247-How-to-Search-for-and-Buy-a-Twilio-Phone-Number-from-Console">Twilio Phone Number</a> with SMS/MMS capabilities.</p>
  </li>
  <li>
    <p>An OS that supports .NET (Windows/macOS/Linux)</p>
  </li>
  <li>
    <p><a href="https://dotnet.microsoft.com/en-us/download/dotnet/6.0">.NET 6.0 SDK</a> (newer and older versions may work too)</p>
  </li>
  <li>
    <p>A code editor or IDE (Recommended: <a href="https://code.visualstudio.com/Download">Visual Studio Code</a> with <a href="https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp">the C# plugin</a>, <a href="https://visualstudio.microsoft.com/">Visual Studio</a>, or <a href="https://www.jetbrains.com/rider/">JetBrains Rider</a>)</p>
  </li>
</ul>

<h2 id="project-overview">Project Overview</h2>

<p>First, let’s understand what the demo intends to achieve. The components involved and the workflow looks like this:</p>

<p><img src="/images/vpblogimg/2026/08/Get-notified-of-new-magazine-issues-using-web-scraping-and-SMS-with-CSharp-dotNET/01.png" alt="Get notified of new magazine issues using web scraping and SMS with C# .NET - image 1" /></p>

<ul>
  <li>
    <p>The worker service reads a database to get the latest issues it sends notifications for.</p>
  </li>
  <li>
    <p>The worker service fetches the website for the magazine and gets the latest issue number. Then, it compares the latest issue number in the database to the latest issue number on the website. If the numbers are equal, it means there is no new issue. If the latest issue number on the website is greater, then there is a new issue. If there is no new issue, the worker service goes to sleep. If there is a new issue, it gets the cover image and the direct link URLs from the magazine’s website.</p>
  </li>
  <li>
    <p>The worker service calls Twilio API to send an SMS/MMS message.</p>
  </li>
  <li>
    <p>Twilio sends the message to the user.</p>
  </li>
  <li>
    <p>The worker service updates its database with the latest issue to avoid duplicate messages.</p>
  </li>
</ul>

<h2 id="project-implementation">Project Implementation</h2>

<p>Let’s start by creating the worker service by running the following commands:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir </span>MagazineTracker
<span class="nb">cd </span>MagazineTracker
dotnet new worker
</code></pre></div></div>

<h3 id="create-the-data-layer">Create the Data Layer</h3>

<p>First, let’s look into the data layer. The only piece of information that needs to be stored is the latest issue number that the application processed.</p>

<p>Create a folder inside your project named Data. Then, create a file LatestMagazineIssue.cs, that contains a model class for your data. Add the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">MagazineTracker.Data</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">LatestMagazineIssue</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="kt">int</span> <span class="n">IssueNumber</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Then, create a new file IMagazineIssueRepository.cs in the Data folder that holds a repository interface to outline the data operations you’re going to use. Add the following code to the file:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">MagazineTracker.Data</span><span class="p">;</span>
<span class="k">public</span> <span class="k">interface</span> <span class="nc">IMagazineIssueRepository</span>
<span class="p">{</span>
    <span class="n">Task</span><span class="p">&lt;</span><span class="n">LatestMagazineIssue</span><span class="p">&gt;</span> <span class="nf">GetLatestIssue</span><span class="p">();</span>
    <span class="n">Task</span> <span class="nf">SaveLatestIssue</span><span class="p">(</span><span class="kt">int</span> <span class="n">latestIssueNumber</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The next step is to decide how to store the data. The requirements of this project are very straightforward, so you don’t need a full-fledged database; a simple JSON file will suffice. Go ahead and create a JSON file named db.json under the Data directory. Update its contents as shown below:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"LatestIssueNumber"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Then, create another file named JsonMagazineIssueRepository.cs in the Data folder which will contain the repository implementation for the JSON file named that implements the previous interface. Update the code as shown below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">System.Text.Json</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.Extensions.Options</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">MagazineTracker.Data</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">JsonMagazineIssueRepository</span> <span class="p">:</span> <span class="n">IMagazineIssueRepository</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">DatabaseSettings</span> <span class="n">_databaseSettings</span><span class="p">;</span>
    <span class="k">public</span> <span class="nf">JsonMagazineIssueRepository</span><span class="p">(</span><span class="n">IOptions</span><span class="p">&lt;</span><span class="n">DatabaseSettings</span><span class="p">&gt;</span> <span class="n">databaseSettings</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_databaseSettings</span> <span class="p">=</span> <span class="n">databaseSettings</span><span class="p">.</span><span class="n">Value</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">LatestMagazineIssue</span><span class="p">&gt;</span> <span class="nf">GetLatestIssue</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">dbAsJson</span> <span class="p">=</span> <span class="k">await</span> <span class="n">File</span><span class="p">.</span><span class="nf">ReadAllTextAsync</span><span class="p">(</span><span class="n">_databaseSettings</span><span class="p">.</span><span class="n">JsonFilePath</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">latestIssue</span> <span class="p">=</span> <span class="n">JsonSerializer</span><span class="p">.</span><span class="n">Deserialize</span><span class="p">&lt;</span><span class="n">LatestMagazineIssue</span><span class="p">&gt;(</span><span class="n">dbAsJson</span><span class="p">);</span>
        <span class="k">return</span> <span class="n">latestIssue</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">SaveLatestIssue</span><span class="p">(</span><span class="kt">int</span> <span class="n">latestIssueNumber</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">dbAsJson</span> <span class="p">=</span> <span class="k">await</span> <span class="n">File</span><span class="p">.</span><span class="nf">ReadAllTextAsync</span><span class="p">(</span><span class="n">_databaseSettings</span><span class="p">.</span><span class="n">JsonFilePath</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">latestIssue</span> <span class="p">=</span> <span class="n">JsonSerializer</span><span class="p">.</span><span class="n">Deserialize</span><span class="p">&lt;</span><span class="n">LatestMagazineIssue</span><span class="p">&gt;(</span><span class="n">dbAsJson</span><span class="p">);</span>
        <span class="n">latestIssue</span><span class="p">.</span><span class="n">IssueNumber</span> <span class="p">=</span> <span class="n">latestIssueNumber</span><span class="p">;</span>
        <span class="n">dbAsJson</span> <span class="p">=</span> <span class="n">JsonSerializer</span><span class="p">.</span><span class="nf">Serialize</span><span class="p">(</span><span class="n">latestIssue</span><span class="p">);</span>
        <span class="k">await</span> <span class="n">File</span><span class="p">.</span><span class="nf">WriteAllTextAsync</span><span class="p">(</span><span class="n">_databaseSettings</span><span class="p">.</span><span class="n">JsonFilePath</span><span class="p">,</span> <span class="n">dbAsJson</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">JsonMagazineIssueRepository</code> only needs one parameter: The path to the JSON file. You can encapsulate it in a simple class. Create DatabaseSettings.cs under the Data directory with the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">MagazineTracker.Data</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">DatabaseSettings</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="n">JsonFilePath</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Then update your appsettings.json file so that it looks like this:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"Logging"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"LogLevel"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"Default"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Information"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"Microsoft.Hosting.Lifetime"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Information"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"DatabaseSettings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"JsonFilePath"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./Data/db.json"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Finally, for this stage, update Program.cs as shown below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">MagazineTracker</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">MagazineTracker.Data</span><span class="p">;</span>
<span class="n">IHost</span> <span class="n">host</span> <span class="p">=</span> <span class="n">Host</span><span class="p">.</span><span class="nf">CreateDefaultBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">ConfigureServices</span><span class="p">((</span><span class="n">hostBuilderContext</span><span class="p">,</span> <span class="n">services</span><span class="p">)</span> <span class="p">=&gt;</span>
    <span class="p">{</span>
        <span class="n">services</span><span class="p">.</span><span class="n">AddHostedService</span><span class="p">&lt;</span><span class="n">Worker</span><span class="p">&gt;();</span>
        <span class="n">services</span><span class="p">.</span><span class="n">AddTransient</span><span class="p">&lt;</span><span class="n">IMagazineIssueRepository</span><span class="p">,</span> <span class="n">JsonMagazineIssueRepository</span><span class="p">&gt;();</span>
        <span class="n">services</span><span class="p">.</span><span class="n">Configure</span><span class="p">&lt;</span><span class="n">DatabaseSettings</span><span class="p">&gt;(</span><span class="n">hostBuilderContext</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetSection</span><span class="p">(</span><span class="s">"DatabaseSettings"</span><span class="p">));</span>
    <span class="p">})</span>
    <span class="p">.</span><span class="nf">Build</span><span class="p">();</span>
<span class="c1">// await host.RunAsync();</span>
<span class="kt">var</span> <span class="n">repo</span> <span class="p">=</span> <span class="n">host</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">GetRequiredService</span><span class="p">&lt;</span><span class="n">IMagazineIssueRepository</span><span class="p">&gt;();</span>
<span class="k">await</span> <span class="n">repo</span><span class="p">.</span><span class="nf">SaveLatestIssue</span><span class="p">(</span><span class="m">120</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">latestIssue</span> <span class="p">=</span> <span class="k">await</span> <span class="n">repo</span><span class="p">.</span><span class="nf">GetLatestIssue</span><span class="p">();</span>
<span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="n">latestIssue</span><span class="p">.</span><span class="n">IssueNumber</span><span class="p">);</span>
</code></pre></div></div>

<p>From line 7 to 9,  is where you register your services with the concrete implementations in the DI container. Then the <code class="language-plaintext highlighter-rouge">IMagazineIssueRepository</code> service is retrieved to get the latest magazine issue and print it to the console.</p>

<p>Line 12 is commented out temporarily to make the implementation/debugging phase easier. As of now, you don’t need to worry about scheduling. That will come later. So, for now, run the application by</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet run
</code></pre></div></div>

<p>And confirm your output looks like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>120
</code></pre></div></div>

<p>Now that you have a working data layer move on to the next section, where you will do some HTML parsing.</p>

<h3 id="html-parse-the-magazine-page">HTML Parse the Magazine Page</h3>

<p>You need 3 things to get from the magazine website:</p>

<ul>
  <li>
    <p>The latest issue number</p>
  </li>
  <li>
    <p>The URL of the magazine (PDF or other formats)</p>
  </li>
  <li>
    <p>The URL of the cover image (Optional)</p>
  </li>
</ul>

<p>Every magazine tracker will work differently but you can combine the requirements above in a single interface so that all the trackers can work in a similar fashion.</p>

<p>Create IMagazineTrackerService.cs for the interface and update its code as shown below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">MagazineTracker</span><span class="p">;</span>
<span class="k">public</span> <span class="k">interface</span> <span class="nc">IMagazineTrackerService</span>
<span class="p">{</span>
    <span class="n">Task</span><span class="p">&lt;</span><span class="kt">int</span><span class="p">&gt;</span> <span class="nf">GetLatestIssueNumber</span><span class="p">();</span>
    <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">GetLatestIssueCoverUrl</span><span class="p">();</span>
    <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">GetIssuePdfUrl</span><span class="p">(</span><span class="kt">int</span> <span class="n">issueNumber</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>All your trackers must implement the <code class="language-plaintext highlighter-rouge">IMagazineTrackerService</code> interface.</p>

<p>Now, implement your first tracker by creating a file MagPiTrackerService.cs with the following dummy implementation:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">MagazineTracker</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">MagPiTrackerService</span> <span class="p">:</span> <span class="n">IMagazineTrackerService</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">int</span><span class="p">&gt;</span> <span class="nf">GetLatestIssueNumber</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">NotImplementedException</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">GetLatestIssueCoverUrl</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">NotImplementedException</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">GetIssuePdfUrl</span><span class="p">(</span><span class="kt">int</span> <span class="n">issueNumber</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">NotImplementedException</span><span class="p">();</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>To do the HTML parsing, you will use a library called <a href="https://anglesharp.github.io/">AngleSharp</a>. It makes the whole process a lot easier, and it can be added to your project via NuGet by running:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package AngleSharp
</code></pre></div></div>

<p>Now, take a look at where to find the latest issue number. The easiest way to find the latest issue number is by going to the<a href="https://magpi.raspberrypi.com/issues/"> </a><a href="https://magpi.raspberrypi.com/issues/">issues page</a>, which looks like this at the time of this writing:</p>

<p><img src="/images/vpblogimg/2026/08/Get-notified-of-new-magazine-issues-using-web-scraping-and-SMS-with-CSharp-dotNET/02.png" alt="Get notified of new magazine issues using web scraping and SMS with C# .NET - image 2" /></p>

<p>If you look at the source of the page (Right click and click Show/View Page Source depending on your browser). If you search the phrase “The MagPi issue 121 out now” (replace the number with the one you see on your screen) you should find the relevant area that looks something like this:</p>

<p>```html hl_lines=”3 4 5 6”</p>
<div class="c-slice c-slice--white">
  <div class="o-container">
    <section class="c-latest-issue">
      <div class="c-latest-issue__cover">
        <a href="/issues/121">
          <img alt="The MagPi issue 121 cover" class="c-latest-issue__image" src="https://magpi.raspberrypi.com/storage/…/MagPi121_COVER_STORE.jpg" />
</a>      </div>
      <div class="c-latest-issue__description">
        <h1 class="o-type-display">
          <a class="c-link" href="/issues/121">The MagPi issue 121 out now!</a>
        </h1>
…
```

This page contains the latest issue number and a URL of the cover image. To parse this page, update the `MagPiTrackerService` code as shown below:

```csharp
using AngleSharp;
namespace MagazineTracker;
public class MagPiTrackerService : IMagazineTrackerService
{
    private const string MagpiRootUrl = "https://magpi.raspberrypi.com";
    public async Task<int> GetLatestIssueNumber()
    {
        var config = Configuration.Default.WithDefaultLoader();
        var context = BrowsingContext.New(config);
        var document = await context.OpenAsync($"{MagpiRootUrl}/issues/");
        var latestCoverLinkSelector = ".c-latest-issue &gt; .c-latest-issue__cover &gt; a";
        var latestCoverLink = document.QuerySelector(latestCoverLinkSelector);
        var rawLink = latestCoverLink.Attributes.GetNamedItem("href").Value;
        return int.Parse(rawLink.Substring(rawLink.LastIndexOf('/') + 1));
    }
    public async Task<string> GetLatestIssueCoverUrl()
    {
        var config = Configuration.Default.WithDefaultLoader();
        var context = BrowsingContext.New(config);
        var document = await context.OpenAsync($"{MagpiRootUrl}/issues/");
        var latestCoverImageSelector = ".c-latest-issue &gt; .c-latest-issue__cover &gt; a &gt; img";
        var latestCoverImage = document.QuerySelector(latestCoverImageSelector);
        var latestCoverImageUrl = latestCoverImage.Attributes.GetNamedItem("src").Value;
        return latestCoverImageUrl;
    }
    public async Task<string> GetIssuePdfUrl(int issueNumber)
    {
        throw new NotImplementedException();
    }
}
```

After loading the page with AngleSharp, you have to write your CSS-selector to get the element you’re interested in. In this example, the latest issue number is obtained from the `href` attribute of the `anchor` element (by parsing the number that follows the latest ‘/’ character)

Similarly, the cover URL is parsed from the `src` attribute of the `img` element.

!!!info

Even though both pieces of information are obtained from the same page, they were implemented as separate methods. This might look repetitive, but the reason for this is to accommodate other trackers. Having both the issue number and cover URL on the same page may not be the case for other magazines, so if you combine them into a single method, you might have issues later on with other trackers.

!!!

To test the latest version, update the Program.cs file as shown below:

```csharp
using MagazineTracker;
using MagazineTracker.Data;
IHost host = Host.CreateDefaultBuilder(args)
    .ConfigureServices((hostBuilderContext, services) =&gt;
    {
        services.AddHostedService<Worker>();
        services.AddTransient&lt;IMagazineIssueRepository, JsonMagazineIssueRepository&gt;();
        services.AddTransient&lt;IMagazineTrackerService, MagPiTrackerService&gt;();
        services.Configure<DatabaseSettings>(hostBuilderContext.Configuration.GetSection("DatabaseSettings"));
    })
    .Build();
// await host.RunAsync();
var repo = host.Services.GetRequiredService<IMagazineIssueRepository>();
var tracker = host.Services.GetRequiredService<IMagazineTrackerService>();
var latestProcessedIssue = await repo.GetLatestIssue();
var latestIssueNumber = await tracker.GetLatestIssueNumber();
if (latestIssueNumber &gt; latestProcessedIssue.IssueNumber)
{
    Console.WriteLine($"New issue detected: {latestIssueNumber}");
    var coverUrl = await tracker.GetLatestIssueCoverUrl();
    Console.WriteLine($"Cover URL: {coverUrl}");
}
```

Now the `IMagazineTrackerService` is also configured as a service and retrieved from the service provider. Then `tracker.GetLatestIssueNumber` and  `tracker.GetLatestIssueCoverUrl` is used to scrape the data and print it.

Run the application, and you should see an output that looks like this:

```
New issue detected: 121
Cover URL: https://magpi.raspberrypi.com/storage/…/MagPi121_COVER_STORE.jpg
```

The third and final piece of information you need is the link to the PDF file. If you click on the “Download Free PDF” link, you get redirected to [https://magpi.raspberrypi.com/issues/121/pdf](https://magpi.raspberrypi.com/issues/121/pdf), which looks like this:

![Get notified of new magazine issues using web scraping and SMS with C# .NET - image 3](/images/vpblogimg/2026/08/Get-notified-of-new-magazine-issues-using-web-scraping-and-SMS-with-CSharp-dotNET/03.png)

!!!info

I'd strongly recommend everybody to consider donating. This is a great magazine with professional quality, and it's full of valuable knowledge about everything Raspberry Pi. 

!!!

If you click on the "No thanks, take me to the free PDF" link, you get redirected to [https://magpi.raspberrypi.com/issues/121/pdf/download](https://magpi.raspberrypi.com/issues/121/pdf/download), and your download starts automatically. This is done by placing an iframe and setting the src as the link to the URL. 

If you look at the source code of the download page and search for “iframe”, you should find the relevant code looks like this:

```html
  <main>
        <iframe src="/downloads/…/MagPi121.pdf" class="u-hidden"></iframe>
```

To parse this URL, update the `MagPiTrackerService.GetIssuePdfUrl` method as shown below:

```csharp
public async Task<string> GetIssuePdfUrl(int issueNumber)
{
    var issueUrl = $"{MagpiRootUrl}/issues/{issueNumber}/pdf/download";
    var config = AngleSharp.Configuration.Default.WithDefaultLoader();
    var address = issueUrl;
    var context = BrowsingContext.New(config);
    var document = await context.OpenAsync(address);
    var cellSelector = "iframe";
    var cell = document.QuerySelector(cellSelector);
    var iframeSrc = cell.Attributes.GetNamedItem("src").Value;
    return $"{MagpiRootUrl}/{iframeSrc.TrimStart('/')}";
}
```

Update the test code in Program.cs only to test the latest update:

```csharp
…
var latestProcessedIssue = await repo.GetLatestIssue();
var latestIssueNumber = await tracker.GetLatestIssueNumber();
if (latestIssueNumber &gt; latestProcessedIssue.IssueNumber)
{
    Console.WriteLine($"New issue detected: {latestIssueNumber}");
    var pdfUrl = await tracker.GetIssuePdfUrl(latestIssueNumber);
    Console.WriteLine($"PDF URL: {pdfUrl}");
}
```

Run the application and confirm you can see the same URL you saw in the download page source:

```
New issue detected: 121
PDF URL: https://magpi.raspberrypi.com/downloads/…/MagPi121.pdf
```

### Set up Twilio to Send SMS Notifications

Before implementing the actual notification mechanism, create a new interface to ensure all notification channels work the same. Create a file named INotificationService.cs and update its code like this:

```csharp
namespace MagazineTracker;
public interface INotificationService
{
    Task SendNewIssueNotification(int issueNumber, string coverUrl, string mediaUrl);
}
```

In the demo project, you will implement SMS/MMS notifications using [Twilio Programmable SMS](https://www.twilio.com/docs/sms).

Now that you have all the information, you need to deliver this to Twilio so that you can get SMS notifications on your mobile device. To achieve this, first, add Twilio SDK to your project by running:

```bash
dotnet add package Twilio
```

You will need your Account SID and Auth Token to be able to talk to the Twilio API. You can find both of these on the welcome page in the account info section when you log in to the [Twilio Console](https://console.twilio.com/):

![Get notified of new magazine issues using web scraping and SMS with C# .NET - image 4](/images/vpblogimg/2026/08/Get-notified-of-new-magazine-issues-using-web-scraping-and-SMS-with-CSharp-dotNET/04.png)

To store these values, you can use environment variables or a vault service, but for local development, you can use [dotnet user secrets](https://docs.microsoft.com/en-us/aspnet/core/security/app-secrets). First, you need to initialize user secrets by running

```bash
dotnet user-secrets init
```

Then, create two new user secrets called `Twilio:AccountSid` and `Twilio:AuthToken` and set the values:

```bash
dotnet user-secrets set Twilio:AccountSid {YOUR TWILIO ACCOUNT SID}
dotnet user-secrets set Twilio:AuthToken {YOUR TWILIO AUTH TOKEN}
```

Create a new file called SmsService.cs and add the following code:

```csharp
using Microsoft.Extensions.Options;
using Twilio.Rest.Api.V2010.Account;
using Twilio.Types;
namespace MagazineTracker;
public class SmsService : INotificationService
{
    private readonly SmsSettings _smsSettings;
    public SmsService(IOptions<SmsSettings> smsSettings)
    {
        _smsSettings = smsSettings.Value;
    }
    public async Task SendNewIssueNotification(int issueNumber, string coverUrl, string mediaUrl)
    {
        MessageResource.Create(
            body: $"Here's the latest issue (#{issueNumber}) of The MagPi Magazine: {mediaUrl}",
            from: new PhoneNumber(_smsSettings.FromPhoneNumber),
            to: new PhoneNumber(_smsSettings.ToPhoneNumber),
            mediaUrl: string.IsNullOrEmpty(coverUrl) ? null : new []
            {
                new Uri(coverUrl)
            }.ToList()
        );
    }
}
```

The SMS message needs to be sent from your Twilio phone number (which you can find right below Account SID and Auth Token on [Twilio Console](https://console.twilio.com/) welcome page). 

The reason the code checks whether or not `coverUrl` has a value is that some Twilio Phones Numbers don’t support MMS. For example, Twilio Phone Numbers from the United Kingdom (UK) do not support MMS, so my UK number could only send plain SMS. So, if you are not able to send MMS messages, simply send an empty string as the cover URL so that setting the `coverUrl` in your worker service looks like this:

```csharp
var coverUrl = String.Empty;
```

Alternatively, you can create a boolean setting such as `includeCoverUrl` to manage this behaviour.

To store both from and to phone numbers, update appsettings.json like this:

```json hl_lines="11 12 13 14"
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "DatabaseSettings": {
    "JsonFilePath": "./Data/db.json"
  },
  "SmsSettings": {
    "FromPhoneNumber": "{YOUR TWILIO PHONE NUMBER}",
    "ToPhoneNumber": "{YOUR ACTUAL PHONE NUMBER}"
  }
}
```

Create a file called SmsSettings.cs  with the following class:

```csharp
namespace MagazineTracker;
public class SmsSettings
{
    public string FromPhoneNumber { get; set; }
    public string ToPhoneNumber { get; set; }
}
```

Finally, update Program.cs to reflect these changes:

```csharp
using MagazineTracker;
using MagazineTracker.Data;
using Twilio;
IHost host = Host.CreateDefaultBuilder(args)
    .ConfigureServices((hostBuilderContext, services) =&gt;
    {
        services.AddHostedService<Worker>();
        services.AddTransient&lt;IMagazineIssueRepository, JsonMagazineIssueRepository&gt;();
        services.AddTransient&lt;IMagazineTrackerService, MagPiTrackerService&gt;();
        services.AddTransient&lt;INotificationService, SmsService&gt;();
        services.Configure<DatabaseSettings>(hostBuilderContext.Configuration.GetSection("DatabaseSettings"));
        services.Configure<SmsSettings>(hostBuilderContext.Configuration.GetSection("SmsSettings"));
        var accountSid = hostBuilderContext.Configuration["Twilio:AccountSid"];
        var authToken = hostBuilderContext.Configuration["Twilio:AuthToken"];
        TwilioClient.Init(accountSid, authToken);
    })
    .Build();
// await host.RunAsync();
var repo = host.Services.GetRequiredService<IMagazineIssueRepository>();
var tracker = host.Services.GetRequiredService<IMagazineTrackerService>();
var notificationService = host.Services.GetRequiredService<INotificationService>();
var latestProcessedIssue = await repo.GetLatestIssue();
var latestIssueNumber = await tracker.GetLatestIssueNumber();
if (latestIssueNumber &gt; latestProcessedIssue.IssueNumber)
{
    Console.WriteLine($"New issue detected: {latestIssueNumber}");
    var coverUrl = await tracker.GetLatestIssueCoverUrl();
    var pdfUrl = await tracker.GetIssuePdfUrl(latestIssueNumber);
    await notificationService.SendNewIssueNotification(latestIssueNumber, coverUrl, pdfUrl);
    await repo.SaveLatestIssue(latestIssueNumber);
}
```

!!!info

Sending a message via WhatsApp works exactly the same way, except you can only use a sandbox environment unless your account is approved. The sandbox session expires after 3 days, so it’s not a great fit for continuous notifications, but if your account is approved already, you can still use `SmsService` without any modifications. All you have to do is replace the “from phone number” with “whatsapp:+xxxxxxxxxxx”, where xxxxxxxxxxx is the number provided to you by Twilio. Also, prefix the “to phone number” with “whatsapp:”

!!!

Time to test the final version (which also updates the database with the latest issue number). Run the application, and you should receive an SMS/MMS on your phone.

My UK Twilio Phone Number doesn’t support MMS. If I try to set the `coverUrl` to the image URL, I get the following exception:

```bash
Twilio.Exceptions.ApiException: Number: +44xxxxxxxxxx has not been enabled for MMS
```

So I set the `coverUrl` to empty string as discussed previously and the SMS I receive on my phone looks like this:

![Get notified of new magazine issues using web scraping and SMS with C# .NET - image 5](/images/vpblogimg/2026/08/Get-notified-of-new-magazine-issues-using-web-scraping-and-SMS-with-CSharp-dotNET/05.png)

And when I tap on the link, I get this:

![Get notified of new magazine issues using web scraping and SMS with C# .NET - image 6](/images/vpblogimg/2026/08/Get-notified-of-new-magazine-issues-using-web-scraping-and-SMS-with-CSharp-dotNET/06.png)

To test the MMS feature, I purchased a US Twilio Phone Number and sent the same message with the actual `coverURL` (meaning reverted the code to its original version: `var coverUrl = await _magazineTrackerService.GetLatestIssueCoverUrl();`).

When I send the message from the US phone number, I get this message:

![Get notified of new magazine issues using web scraping and SMS with C# .NET - image 7](/images/vpblogimg/2026/08/Get-notified-of-new-magazine-issues-using-web-scraping-and-SMS-with-CSharp-dotNET/07.png)

It shows the text, the full URL to the PDF and a shortened URL of the cover image.

In my case, I prefer the original message. Depending on your phone, carrier and the messaging app you use, your experience may vary. I’d recommend playing around with splitting up the notification into multiple messages, such as sending the text in one message and the cover image in another or sending text, cover image, and URL all in different messages. Try it out and decide which format you like the most.

### Schedule the Worker Service

You have a working application but it only functions when you run it manually. To automate the process, move the code into the Worker.cs class shown below:

```csharp
using MagazineTracker.Data;
namespace MagazineTracker;
public class Worker : BackgroundService
{
    private readonly ILogger<Worker> _logger;
    private readonly IMagazineIssueRepository _magazineIssueRepository;
    private readonly IMagazineTrackerService _magazineTrackerService;
    private readonly INotificationService _notificationService;
    public Worker(ILogger<Worker> logger, IMagazineIssueRepository magazineIssueRepository, IMagazineTrackerService magazineTrackerService, INotificationService notificationService)
    {
        _logger = logger;
        _magazineIssueRepository = magazineIssueRepository;
        _magazineTrackerService = magazineTrackerService;
        _notificationService = notificationService;
    }
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
            var latestProcessedIssue = await _magazineIssueRepository.GetLatestIssue();
            var latestIssueNumber = await _magazineTrackerService.GetLatestIssueNumber();
            if (latestIssueNumber &gt; latestProcessedIssue.IssueNumber)
            {
                _logger.LogInformation("New issue detected: {latestIssueNumber}", latestIssueNumber);
                var coverUrl = await _magazineTrackerService.GetLatestIssueCoverUrl();
                var pdfUrl = await _magazineTrackerService.GetIssuePdfUrl(latestIssueNumber);
                await _notificationService.SendNewIssueNotification(latestIssueNumber, coverUrl, pdfUrl);
                await _magazineIssueRepository.SaveLatestIssue(latestIssueNumber);
            }
            else
            {
                _logger.LogInformation("No new issue is detected.");
            }
            await Task.Delay(1000 * 60 * 60, stoppingToken); // Run hourly
        }
    }
}
```

This way, you can remove all the previous test code and initializations and Program.cs becomes very concise:

```csharp
using MagazineTracker;
using MagazineTracker.Data;
using Twilio;
IHost host = Host.CreateDefaultBuilder(args)
    .ConfigureServices((hostBuilderContext, services) =&gt;
    {
        services.AddHostedService<Worker>();
        services.AddTransient&lt;IMagazineIssueRepository, JsonMagazineIssueRepository&gt;();
        services.AddTransient&lt;IMagazineTrackerService, MagPiTrackerService&gt;();
        services.AddTransient&lt;INotificationService, SmsService&gt;();
        services.Configure<DatabaseSettings>(hostBuilderContext.Configuration.GetSection("DatabaseSettings"));
        services.Configure<SmsSettings>(hostBuilderContext.Configuration.GetSection("SmsSettings"));
        var accountSid = hostBuilderContext.Configuration["Twilio:AccountSid"];
        var authToken = hostBuilderContext.Configuration["Twilio:AuthToken"];
        TwilioClient.Init(accountSid, authToken);
    })
    .Build();
await host.RunAsync();
```

Now run the application again (reset the database first to a value lower than the latest issue number), and you should receive an SMS/MMS; your database should be updated with the latest issue number, and your service should wait for 1 hour and then run the code again. You can, of course, change how often you would like to check for new issues by changing the delay.

## Conclusion

My favorite projects are the ones that I develop to solve a real problem of mine. This one was a small issue, but I like the idea of automating something that otherwise I’d forget. Even though there is one implementation of a magazine tracker service, you can adapt the existing code for your favorite publication. As long as you add a new class that implements the same interface, you can replace the registration code in Program.cs and your application will start fetching that magazine. The same goes for the notification. You can replace SMS/MMS with email using [SendGrid](https://www.twilio.com/blog/send-emails-using-the-sendgrid-api-with-dotnetnet-6-and-csharp) or [WhatsApp](https://www.twilio.com/blog/send-a-whatsapp-message-with-c-in-30-seconds).

If you'd like to keep learning, I recommend taking a look at these articles:

- [How to send vCards with WhatsApp using C# and .NET](https://www.twilio.com/blog/send-vcards-with-whatsapp-using-csharp-and-dotnet)

- [Send Emails with C#, Handlebars templating, and Dynamic Email Templates](https://www.twilio.com/blog/send-emails-with-csharp-handlebars-templating-and-dynamic-email-templates)

- [Render Emails Using Razor Templating](https://www.twilio.com/blog/render-emails-using-razor-templating)
</SmsSettings></DatabaseSettings></Worker></Worker></Worker></INotificationService></IMagazineTrackerService></IMagazineIssueRepository></SmsSettings></DatabaseSettings></Worker></SmsSettings></string></main></IMagazineTrackerService></IMagazineIssueRepository></DatabaseSettings></Worker></string></string></int></div></section></div></div>
]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[Generate images with DALL·E 2 and Twilio SMS using ASP.NET Core]]></title>
    <link href="https://volkanpaksoy.com/archive/2026/08/05/Generate-images-with-DALL-E-2-and-Twilio-SMS-using-ASPNET-Core/"/>
    <updated>2026-08-05T12:35:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2026/08/05/Generate-images-with-DALL-E-2-and-Twilio-SMS-using-ASPNET-Core</id>
    <content type="html"><![CDATA[<blockquote>
  <p>This article was originally published on the <a href="https://www.twilio.com/en-us/blog/developers/community/generate-images-with-dall-e-2-and-twilio-sms-using-aspnet-core">Twilio Blog</a>.</p>
</blockquote>

<p>Recently, there has been a massive boost in AI-generated art. It came to a point that an AI-generated piece of art <a href="https://www.nytimes.com/2022/09/02/technology/ai-artificial-intelligence-artists.html">won a contest</a> a few months ago. There are many art generation programs available such as <a href="https://openai.com/dall-e-2/">OpenAI DALL·E 2</a>, <a href="https://www.midjourney.com/">Midjourney</a>, <a href="https://stability.ai/">Stable Diffusion</a>, etc. In this article, you will use DALL·E 2 to generate images. They recently made their system available to the general public without a waitlist and also opened their API. They also give free credits so you can follow this article for free. You can also get the final source code from <a href="https://github.com/Dev-Power/get-ai-generated-images-with-sms">my GitHub repository</a>.</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>You’ll need the following things in this tutorial:</p>

<ul>
  <li>
    <p>A free <a href="https://www.twilio.com/try-twilio">Twilio account</a></p>
  </li>
  <li>
    <p>A <a href="https://support.twilio.com/hc/en-us/articles/223135247-How-to-Search-for-and-Buy-a-Twilio-Phone-Number-from-Console">Twilio Phone Number</a> with SMS/MMS capabilities.</p>
  </li>
  <li>
    <p>An OS that supports .NET (Windows/macOS/Linux)</p>
  </li>
  <li>
    <p><a href="https://dotnet.microsoft.com/en-us/download/dotnet/6.0">.NET 6.0 SDK</a> (newer and older versions may work too)</p>
  </li>
  <li>
    <p>A code editor or IDE (Recommended: <a href="https://code.visualstudio.com/Download">Visual Studio Code</a> with <a href="https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp">the C# plugin</a>, <a href="https://visualstudio.microsoft.com/">Visual Studio</a>, or <a href="https://www.jetbrains.com/rider/">JetBrains Rider</a>)</p>
  </li>
  <li>
    <p>A free <a href="https://labs.openai.com/signup">OpenAI account</a></p>
  </li>
  <li>
    <p><a href="https://ngrok.com/">ngrok</a> (A <a href="https://dashboard.ngrok.com/signup">free ngrok account</a> is sufficient for this tutorial)</p>
  </li>
</ul>

<h2 id="openai-and-dall-e-2">OpenAI and DALL-E 2</h2>

<p>OpenAI started as a non-profit artificial intelligence research organization founded by Elon Musk and Sam Altman. Elon Musk later quit the company. Currently, it operates under <a href="https://openai.com/blog/openai-lp/">OpenAI LP</a>, a “capped-profit” company (a hybrid of profit and non-profit models).</p>

<p>DALL-E, is a machine learning model that uses GPT-3 to generate realistic images from a description. It was initially announced in January 2021. The latest iteration of the system, DALL-E 2, was announced in April 2022. It initially required joining a waiting list, and after you’ve been accepted, you could only generate images using their web front-end. Those limitations have now been lifted, and you can sign up and start using their API to generate images.</p>

<h3 id="overview-of-dall-e-2-front-end">Overview of DALL-E 2 Front-End</h3>

<p>When you go to the <a href="https://openai.com/dall-e-2/">DALL-E 2 website</a> and log in, you see an input box and a Generate button.</p>

<p><img src="/images/vpblogimg/2026/08/Generate-images-with-DALL-E-2-and-Twilio-SMS-using-ASPNET-Core/01.png" alt="Generate images with DALL·E 2 and Twilio SMS using ASP.NET Core - image 1" /></p>

<p>The simplicity in the design reminds me of the Google homepage. It even has a “Surprise me” option which is similar to the “I’m feeling lucky” button.</p>

<p>Enter your description and press the Generate button. In a matter of seconds, you will see 4 image suggestions generated for you based on your description as shown below.</p>

<p><img src="/images/vpblogimg/2026/08/Generate-images-with-DALL-E-2-and-Twilio-SMS-using-ASPNET-Core/02.png" alt="Generate images with DALL·E 2 and Twilio SMS using ASP.NET Core - image 2" /></p>

<p>You get 50 credits upon sign up but they expire after a month. After that, you get 15 free credits every month. 1 image generation costs 1 credit. You can check your credit status by clicking on your profile image on the upper right-hand corner.</p>

<p><img src="/images/vpblogimg/2026/08/Generate-images-with-DALL-E-2-and-Twilio-SMS-using-ASPNET-Core/03.png" alt="Generate images with DALL·E 2 and Twilio SMS using ASP.NET Core - image 3" /></p>

<p>If you hover over the images, you see a “…” button appear. Click on it and the “Quick Actions” menu opens.</p>

<p><img src="/images/vpblogimg/2026/08/Generate-images-with-DALL-E-2-and-Twilio-SMS-using-ASPNET-Core/04.png" alt="Generate images with DALL·E 2 and Twilio SMS using ASP.NET Core - image 4" /></p>

<p>Here you can download the image or generate more variations based on this. The new variations are quite similar to the original one though, as they are all based on the same description.</p>

<p><img src="/images/vpblogimg/2026/08/Generate-images-with-DALL-E-2-and-Twilio-SMS-using-ASPNET-Core/05.png" alt="Generate images with DALL·E 2 and Twilio SMS using ASP.NET Core - image 5" /></p>

<p>You can keep on generating variations from variations as well. Generating variations also costs 1 credit.</p>

<h3 id="overview-of-openai-account">Overview of OpenAI Account</h3>

<p>To be able to use the OpenAI API, you will need an API key and some credits. When you sign up, OpenAI gives you free credits. Note that this is different from the 50 credits DALL-E 2 gave.</p>

<p>To check your credit status, go to your <a href="https://beta.openai.com/account">account page</a>.</p>

<p>You should see your usage breakdown and your credit status.</p>

<p><img src="/images/vpblogimg/2026/08/Generate-images-with-DALL-E-2-and-Twilio-SMS-using-ASPNET-Core/06.png" alt="Generate images with DALL·E 2 and Twilio SMS using ASP.NET Core - image 6" /></p>

<p>You get quite a lot of credits ($18) considering 1 image generation costs $0.02. There are even cheaper options depending on the image size and generation model.</p>

<p><img src="/images/vpblogimg/2026/08/Generate-images-with-DALL-E-2-and-Twilio-SMS-using-ASPNET-Core/07.png" alt="Generate images with DALL·E 2 and Twilio SMS using ASP.NET Core - image 7" /></p>

<p>You can check the <a href="https://openai.com/api/pricing/">pricing page</a> for full details.</p>

<p>After you’ve confirmed you have free credits granted, click on API Keys on the left menu.</p>

<p>Here, click on the Create new secret key button.</p>

<p>As the prompt says, save your secret key somewhere safe as you’ll not have another chance to see it.</p>

<p><img src="/images/vpblogimg/2026/08/Generate-images-with-DALL-E-2-and-Twilio-SMS-using-ASPNET-Core/08.png" alt="Generate images with DALL·E 2 and Twilio SMS using ASP.NET Core - image 8" /></p>

<p>Now that you’ve familiarized yourself with image generation, have your API key and credits available, move on to the next section to implement your own API to send the DALL-E-2-generated images.</p>

<h2 id="project-implementation">Project Implementation</h2>

<p>To be able to generate the images, you will need to receive the image description from the user via SMS. To achieve this, you will implement a web API that responds to Twilio SMS webhook requests.</p>

<p>Create the API by running the following commands in a terminal:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir </span>Dalle2ImageSmsApi
<span class="nb">cd </span>Dalle2ImageSmsApi
dotnet new webapi
</code></pre></div></div>

<p>Since you’re going to use Twilio, add Twilio .NET SDK and ASP.NET helper library via NuGet:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package Twilio
dotnet add package Twilio.AspNet.Core
</code></pre></div></div>

<p>Open the project with your IDE.</p>

<p>Under the Controllers directory, create a new file called IncomingSmsController.cs and update its contents with the code below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Mvc</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.AspNet.Core</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.TwiML</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.TwiML.Messaging</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">Dalle2ImageSmsApi.Controllers</span><span class="p">;</span>
<span class="p">[</span><span class="n">ApiController</span><span class="p">]</span>
<span class="p">[</span><span class="nf">Route</span><span class="p">(</span><span class="s">"[controller]"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">IncomingSmsController</span> <span class="p">:</span> <span class="n">TwilioController</span>
<span class="p">{</span>
    <span class="p">[</span><span class="n">HttpPost</span><span class="p">]</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">TwiMLResult</span><span class="p">&gt;</span> <span class="nf">Index</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">form</span> <span class="p">=</span> <span class="k">await</span> <span class="n">Request</span><span class="p">.</span><span class="nf">ReadFormAsync</span><span class="p">();</span>
        <span class="kt">var</span> <span class="n">incomingText</span> <span class="p">=</span> <span class="n">form</span><span class="p">[</span><span class="s">"Body"</span><span class="p">];</span>
        <span class="kt">var</span> <span class="n">message</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">Message</span><span class="p">();</span>
        <span class="n">message</span><span class="p">.</span><span class="nf">Body</span><span class="p">(</span><span class="s">$"Here's the image for your query: </span><span class="p">{</span><span class="n">incomingText</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
        <span class="n">message</span><span class="p">.</span><span class="nf">Media</span><span class="p">(</span><span class="k">new</span> <span class="nf">Uri</span><span class="p">(</span><span class="s">"https://picsum.photos/1024/1024"</span><span class="p">));</span>
        <span class="k">return</span> <span class="k">new</span> <span class="nf">MessagingResponse</span><span class="p">()</span>
            <span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">ToTwiMLResult</span><span class="p">();</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The code above extracts the text message sent by the user (which is sent in the <code class="language-plaintext highlighter-rouge">Body</code> paramater field of the form encoded request body.).</p>

<p>This message will be used to generate the image. For now, just for testing purposes, you will ignore this message and return a random photo from an online service called <a href="https://picsum.photos/">picsum.photos</a> which is a handy service to create random placeholder images. You can use this service to test and format your response messages without wasting your OpenAI credits.</p>

<p>Run the application by running the following command in the terminal window:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet run
</code></pre></div></div>

<p>You should see your application running on your localhost.</p>

<p><img src="/images/vpblogimg/2026/08/Generate-images-with-DALL-E-2-and-Twilio-SMS-using-ASPNET-Core/09.png" alt="Generate images with DALL·E 2 and Twilio SMS using ASP.NET Core - image 9" /></p>

<p>You want Twilio to send webhook requests to your API, but currently Twilio cannot access your localhost. To fix this issue, you will tunnel your localhost to the internet with ngrok.</p>

<p>Copy the localhost URL, open another terminal and run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ngrok http <span class="o">{</span> YOUR LOCALHOST URL <span class="o">}</span>
</code></pre></div></div>

<p>Replace <code class="language-plaintext highlighter-rouge">{ YOUR LOCALHOST URL }</code> with the value you copied from the other terminal (It would be http://localhost:5252 in this example)</p>

<p>You should see ngrok generate a random Forwarding URL and ngrok will now forward the traffic from this URL to your local API:</p>

<p><img src="/images/vpblogimg/2026/08/Generate-images-with-DALL-E-2-and-Twilio-SMS-using-ASPNET-Core/10.png" alt="Generate images with DALL·E 2 and Twilio SMS using ASP.NET Core - image 10" /></p>

<p>Now that you have a publicly accessible URL, you can tell Twilio where to send webhook requests.</p>

<p>Go to <a href="https://console.twilio.com/">Twilio Console</a>. Then go to <a href="https://console.twilio.com/us1/develop/phone-numbers/manage/incoming">Phone Numbers → Manage → Active Numbers</a> and click on your number.</p>

<p>Scroll down to the messaging section. Select Webhook in the “A MESSAGE COMES IN” part and enter your ngrok URL followed by /IncomingSms as shown below:</p>

<p><img src="/images/vpblogimg/2026/08/Generate-images-with-DALL-E-2-and-Twilio-SMS-using-ASPNET-Core/11.png" alt="Generate images with DALL·E 2 and Twilio SMS using ASP.NET Core - image 11" /></p>

<p>Click Save.</p>

<p>Now that the environment is set up, send an SMS to your Twilio phone and see if you can receive a random image as response. If you can receive the image, it means it’s now time to generate images using the OpenAI API.</p>

<h3 id="implement-openai-client">Implement OpenAI Client</h3>

<p>The <a href="https://beta.openai.com/docs/guides/images/introduction">image generation API</a> is still in beta as of this writing. Using image generation is quite straightforward. You send the description of the image, size, and the number of images you want to get. In this project, you will use an open-source client <a href="https://github.com/betalgo/openai">library</a> which has recently been updated to support DALL-E.</p>

<p>Stop your application if it’s still running and run the following command in the terminal window:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package Betalgo.OpenAI.GPT3
</code></pre></div></div>

<p>You will need your OpenAI API key to use the API that you created in the previous section. You will use <a href="https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets#secret-manager">.NET user secrets</a> to store it. Run the following command to initialize the user secrets:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet user-secrets init
</code></pre></div></div>

<p>Then, add the API key to the secrets by running the following command, replacing <code class="language-plaintext highlighter-rouge">{YOUR OPENAI API KEY}</code> with the actual API key value:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet user-secrets <span class="nb">set </span>OpenAIServiceOptions:ApiKey <span class="o">{</span>YOUR OPENAI API KEY<span class="o">}</span>
</code></pre></div></div>

<p>Update Program.cs and add the highlighted lines:</p>

<p>```csharp hl_lines=”1 11”
using OpenAI.GPT3.Extensions;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddOpenAIService();</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
Now you can inject the service to your `IncomingSmsController` controller as shown below. The highlighted lines are what’s new and updated.

```csharp hl_lines="2 3 14 16 17 18 19 20 21 22 23 24 25 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 56"
using Microsoft.AspNetCore.Mvc;
using OpenAI.GPT3.Interfaces;
using OpenAI.GPT3.ObjectModels.RequestModels;
using Twilio.AspNet.Core;
using Twilio.TwiML;
using Twilio.TwiML.Messaging;
namespace Dalle2ImageSmsApi.Controllers;
[ApiController]
[Route("[controller]")]
public class IncomingSmsController : TwilioController
{
    private readonly ILogger&lt;IncomingSmsController&gt; _logger;
    private readonly IOpenAIService _openAiService;
    public IncomingSmsController(
        ILogger&lt;IncomingSmsController&gt; logger, 
        IOpenAIService openAiService
    )
    {
        _logger = logger;
        _openAiService = openAiService;
    }
    [HttpPost]
    public async Task&lt;TwiMLResult&gt; Index()
    {
        var form = await Request.ReadFormAsync();
        var incomingText = form["Body"];
        var createImageRequest = new ImageCreateRequest
        {
            Size = "1024x1024",
            N = 1,
            Prompt = incomingText,
            ResponseFormat = "url"
        };
        var createImageResponse = await _openAiService.Image.CreateImage(createImageRequest);
        if(!createImageResponse.Successful)
        {
            var errorMessage = "An error occurred trying to create OpenAI image." +
                $" {createImageResponse.Error.Code}: {createImageResponse.Error.Message}.";
            _logger.LogError(errorMessage);
            return new MessagingResponse()
                .Message("An unexpected error occurred. Try again later.")
                .ToTwiMLResult();
        }
        var image = createImageResponse.Results.First();
        var message = new Message();
        message.Body($"Here's the image for your query: {incomingText}");
        message.Media(new Uri(image.Url));
        return new MessagingResponse()
            .Append(message)
            .ToTwiMLResult();
    }
}
</code></pre></div></div>

<p>The action now construct a <code class="language-plaintext highlighter-rouge">CreateImageRequest</code> object with size set to <code class="language-plaintext highlighter-rouge">1024x1024</code>, the number of images requested set to <code class="language-plaintext highlighter-rouge">1</code>, and passes in the image description received from the incoming text message. It also sets the response format to <code class="language-plaintext highlighter-rouge">url</code> as it will pass Twilio the URL of the image. Valid values for <code class="language-plaintext highlighter-rouge">ResponseFormat</code> are <code class="language-plaintext highlighter-rouge">url</code> and <code class="language-plaintext highlighter-rouge">b64_json</code>.</p>

<p>Run the application again. To test the implementation, send an SMS to your Twilio phone again. This time the image returned should match your description.</p>

<p>For example, I sent the following description: “a golden retriever puppy playing with a kitten”:</p>

<p><img src="/images/vpblogimg/2026/08/Generate-images-with-DALL-E-2-and-Twilio-SMS-using-ASPNET-Core/12.png" alt="Generate images with DALL·E 2 and Twilio SMS using ASP.NET Core - image 12" /></p>

<p>When I clicked the link, I got the following image:</p>

<p><img src="/images/vpblogimg/2026/08/Generate-images-with-DALL-E-2-and-Twilio-SMS-using-ASPNET-Core/13.png" alt="Generate images with DALL·E 2 and Twilio SMS using ASP.NET Core - image 13" /></p>

<p>I’m happy with the puppy but the kitten seems a bit odd. Of course, the nice thing about it is, if you don’t like the result, you can always request more.</p>

<p>As an improvement, you can make the image size and the number of images customizable.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this tutorial, you learned the basics of DALL-E 2 via using the front-end. Then implemented your own service to interact with OpenAI API. The service responds to Twilio SMS webhooks and uses DALL-E 2 to generate images based on the user’s description and sends the image back to the user.</p>

<p>The resulting image may or may not be satisfactory based on the description. These are the early days of AI-generated images and I’m sure they will keep on getting better and better.</p>

<p>If you’d like to keep learning, I recommend taking a look at these articles:</p>

<ul>
  <li>
    <p><a href="https://www.twilio.com/blog/configure-twilio-webhooks-with-visual-studio-dev-tunnels-during-aspdotnet-core-startup">Configure Twilio Webhooks automatically with Visual Studio dev tunnels during ASP.NET Core startup</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/integrate-ngrok-into-aspdotnet-core-startup-and-automatically-update-your-webhook-urls">Integrate ngrok into ASP.NET Core startup and automatically update your webhook URLs</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/get-notified-of-new-magazine-issues-using-web-scraping-and-sms-with-csharp-dotnet">Get notified of new magazine issues using web scraping and SMS with C# .NET</a></p>
  </li>
</ul>
]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[Forward Voicemails with Transcript to your Email using C# and ASP.NET Core]]></title>
    <link href="https://volkanpaksoy.com/archive/2026/08/05/Forward-Voicemails-with-Transcript-to-your-Email-using-CSharp-and-ASPNET-Core/"/>
    <updated>2026-08-05T12:30:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2026/08/05/Forward-Voicemails-with-Transcript-to-your-Email-using-CSharp-and-ASPNET-Core</id>
    <content type="html"><![CDATA[<blockquote>
  <p>This article was originally published on the <a href="https://www.twilio.com/en-us/blog/forward-voicemails-with-transcript-to-your-email-using-csharp-and-aspnetcore">Twilio Blog</a>.</p>
</blockquote>

<p>Whether you like it or not, phone calls are essential to our daily communications. However, sometimes nobody is available to take the call right there and then. Luckily, Twilio Programmable Voice lets you <a href="https://www.twilio.com/docs/voice/tutorials/how-to-record-phone-calls/csharp">record</a> voicemail so the caller can leave a message. But what if instead of having to call into a voicemail box, you could receive the voicemail and transcript in as an email instead? In this article, you will build a Twilio Voice app that sends voicemails and the call transcript to your email address using SendGrid.</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>You’ll need the following things in this tutorial:</p>

<ul>
  <li>
    <p>A free Twilio account (<a href="https://www.twilio.com/referral/ZOvl3g">sign up with Twilio using this link</a> and get $10 in free credit when you upgrade your account)</p>
  </li>
  <li>
    <p>A <a href="https://support.twilio.com/hc/en-us/articles/223135247-How-to-Search-for-and-Buy-a-Twilio-Phone-Number-from-Console">Twilio Phone Number</a></p>
  </li>
  <li>
    <p>A free or paid SendGrid account. <a href="https://signup.sendgrid.com/">Sign up for a SendGrid account here</a> to send up to 100 emails per day completely free of charge.</p>
  </li>
  <li>
    <p>SendGrid API Key (See <a href="https://docs.sendgrid.com/ui/account-and-settings/api-keys">Manage SendGrid API Keys</a>)</p>
  </li>
  <li>
    <p>A verified Sender email or domain to send emails from (See <a href="https://docs.sendgrid.com/ui/sending-email/senders#adding-a-sender">Adding a Sender</a>)</p>
  </li>
  <li>
    <p>An OS that supports .NET (Windows/macOS/Linux)</p>
  </li>
  <li>
    <p><a href="https://dotnet.microsoft.com/en-us/download/dotnet/6.0">.NET 6.0 SDK</a> (newer and older versions may work too)</p>
  </li>
  <li>
    <p>A code editor or IDE (Recommended: <a href="https://code.visualstudio.com/Download">Visual Studio Code</a> with <a href="https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp">the C# plugin</a>, <a href="https://visualstudio.microsoft.com/">Visual Studio</a>, or <a href="https://www.jetbrains.com/rider/">JetBrains Rider</a>)</p>
  </li>
  <li>
    <p><a href="https://ngrok.com/">ngrok</a> (A <a href="https://dashboard.ngrok.com/signup">free ngrok account</a> is sufficient for this tutorial)</p>
  </li>
</ul>

<h2 id="project-overview">Project Overview</h2>

<p>Before jumping into the code, let’s take a look at how the application will work.</p>

<p>Take a look at this diagram of the application flow:</p>

<p><img src="/images/vpblogimg/2026/08/Forward-Voicemails-with-Transcript-to-your-Email-using-CSharp-and-ASPNET-Core/01.png" alt="Forward Voicemails with Transcript to your Email using C# and ASP.NET Core - image 1" /></p>

<ul>
  <li>
    <p>Someone calls your Twilio Phone Number. Twilio picks up the call and forwards the details via HTTP to your Web API.</p>
  </li>
  <li>
    <p>Your Web API responds with TwiML instructions. These instructions tell Twilio what to do with the phone call. You’ll learn more about TwiML later. Your TwiML instructions tell Twilio to record a voicemail and to send the recording transcript back to your Web API.</p>
  </li>
  <li>
    <p>The caller leaves a message which Twilio records and transcribes.</p>
  </li>
  <li>
    <p>When Twilio is done transcribing the recording, Twilio sends the transcription via HTTP to your Web API.</p>
  </li>
  <li>
    <p>Your Web API will download the voicemail audio file (as MP3) and then use SendGrid to send an email with the phone number of the caller, the transcript of the voicemail, and the voicemail audio file itself.</p>
  </li>
  <li>
    <p>SendGrid will deliver the email to your email inbox.</p>
  </li>
</ul>

<p>When Twilio receives a phone call, Twilio will send the details as an HTTP request to a URL that you configure and expect instruction as an HTTP response. This concept is called a <a href="https://www.twilio.com/docs/usage/webhooks">webhook</a> and is commonly used across Twilio products.</p>

<p>However, Twilio can only send HTTP requests to publicly available URLs, and you’ll be developing your application locally. To solve this, you’ll tunnel your localhost publicly using the free ngrok service. This wouldn’t be necessary in production, but is necessary for Twilio to reach your locally running web application. More on this later!</p>

<p>To instruct Twilio what to do with the phone call, you need to respond to the webhook HTTP request using a specific set of instructions called the <a href="https://www.twilio.com/docs/glossary/what-is-twilio-markup-language-twiml">Twilio Markup Language</a>, or TwiML for short. TwiML is a specific set of XML tags that you can use to tell Twilio how to respond to voice calls and text messages. In this application you will use these two TwiML verbs: <code class="language-plaintext highlighter-rouge">&lt;Say&gt;</code> and <code class="language-plaintext highlighter-rouge">&lt;Record&gt;</code>.</p>

<p><code class="language-plaintext highlighter-rouge">&lt;Say&gt;</code> will convert text to speech and send the audio to the caller. <code class="language-plaintext highlighter-rouge">&lt;Record&gt;</code> will record the audio of the phone call which you will use to implement voicemail functionality. These TwiML verbs can also have attributes and nested noun-tags. To instruct Twilio to transcribe the recording and send the transcription to your web application, you’ll be using the <code class="language-plaintext highlighter-rouge">transcribe</code> and the <code class="language-plaintext highlighter-rouge">transcribeCallback</code> attribute. Using these TwiML verbs and attributes, you’ll generate TwiML that looks like this:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?xml version="1.0" encoding="utf-8"?&gt;</span>
<span class="nt">&lt;Response&gt;</span>
  <span class="nt">&lt;Say&gt;</span>Hello. I'm not available at the moment. Please leave a message after the beep.<span class="nt">&lt;/Say&gt;</span>
  <span class="nt">&lt;Record</span> <span class="na">timeout=</span><span class="s">"10"</span> <span class="na">transcribe=</span><span class="s">"true"</span> <span class="na">transcribeCallback=</span><span class="s">"/TranscribeCallback"</span><span class="nt">&gt;&lt;/Record&gt;</span>
<span class="nt">&lt;/Response&gt;</span>
</code></pre></div></div>

<p>!!!info</p>

<p>When you pass in a relative URL to <code class="language-plaintext highlighter-rouge">transcribeCallback</code>, Twilio will resolve the relative URL relatively to the URL it sent the HTTP request to. When using an absolute URL, Twilio will resolve the URL relatively to the root path of the URL it sent the HTTP request to.</p>

<p>!!!</p>

<p>This is all the TwiML you’ll be using in this application, but I recommend learning more about <a href="https://www.twilio.com/docs/voice/twiml">TwiML for Voice in the docs</a>, and specifically to look deeper into the <a href="https://www.twilio.com/docs/voice/twiml/say">Say-verb</a> and the <a href="https://www.twilio.com/docs/voice/twiml/record">Record-verb</a>.</p>

<p>Now that you understand how the application will work,  let’s get started.</p>

<h2 id="create-the-aspnet-core-web-api">Create the ASP.NET Core Web API</h2>

<p>!!!info</p>

<p>If you’d prefer to get the final project directly, you can get it from my <a href="https://github.com/cloudinternals/voicemail-forwarder">GitHub repository</a>, or follow the steps below to implement it yourself.</p>

<p>!!!</p>

<p>The first step is to create a new Web API project to handle the Twilio webhooks and send the emails. You can do this by opening a terminal and running these commands:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir </span>VoicemailForwarderWebApi
<span class="nb">cd </span>VoicemailForwarderWebApi
dotnet new webapi
</code></pre></div></div>

<p>Run the application to confirm everything is in good order:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet run
</code></pre></div></div>

<p>Your output should look like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:7117
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5162
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
</code></pre></div></div>

<p>This project template comes with a WeatherForecast controller. Open a new browser tab and browse to your HTTP URL with the /WeatherForecast path (in this example: <a href="http://localhost:5162/WeatherForecast">http://localhost:5162/WeatherForecast</a>). You should see results like this:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[{</span><span class="nl">"date"</span><span class="p">:</span><span class="s2">"2022-08-17T13:41:31.816102+01:00"</span><span class="p">,</span><span class="nl">"temperatureC"</span><span class="p">:</span><span class="mi">18</span><span class="p">,</span><span class="nl">"temperatureF"</span><span class="p">:</span><span class="mi">64</span><span class="p">,</span><span class="nl">"summary"</span><span class="p">:</span><span class="s2">"Cool"</span><span class="p">},{</span><span class="nl">"date"</span><span class="p">:</span><span class="s2">"2022-08-18T13:41:31.817042+01:00"</span><span class="p">,</span><span class="nl">"temperatureC"</span><span class="p">:</span><span class="mi">52</span><span class="p">,</span><span class="nl">"temperatureF"</span><span class="p">:</span><span class="mi">125</span><span class="p">,</span><span class="nl">"summary"</span><span class="p">:</span><span class="s2">"Balmy"</span><span class="p">},{</span><span class="nl">"date"</span><span class="p">:</span><span class="s2">"2022-08-19T13:41:31.817048+01:00"</span><span class="p">,</span><span class="nl">"temperatureC"</span><span class="p">:</span><span class="mi">21</span><span class="p">,</span><span class="nl">"temperatureF"</span><span class="p">:</span><span class="mi">69</span><span class="p">,</span><span class="nl">"summary"</span><span class="p">:</span><span class="s2">"Mild"</span><span class="p">},{</span><span class="nl">"date"</span><span class="p">:</span><span class="s2">"2022-08-20T13:41:31.81705+01:00"</span><span class="p">,</span><span class="nl">"temperatureC"</span><span class="p">:</span><span class="mi">-10</span><span class="p">,</span><span class="nl">"temperatureF"</span><span class="p">:</span><span class="mi">15</span><span class="p">,</span><span class="nl">"summary"</span><span class="p">:</span><span class="s2">"Mild"</span><span class="p">},{</span><span class="nl">"date"</span><span class="p">:</span><span class="s2">"2022-08-21T13:41:31.817051+01:00"</span><span class="p">,</span><span class="nl">"temperatureC"</span><span class="p">:</span><span class="mi">-19</span><span class="p">,</span><span class="nl">"temperatureF"</span><span class="p">:</span><span class="mi">-2</span><span class="p">,</span><span class="nl">"summary"</span><span class="p">:</span><span class="s2">"Mild"</span><span class="p">}]</span><span class="w">
</span></code></pre></div></div>

<p>This setup works fine in your local environment, but for Twilio to be able to send HTTP requests to your endpoints, your API needs to be publicly accessible over the internet.</p>

<p>You can achieve that with ngrok, which tunnels public requests to your local machine.</p>

<p>Leave your .NET app running, then open a separate terminal and run ngrok with the following command:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ngrok http YOUR_HTTP_PORT
</code></pre></div></div>

<p>You should see some random URL generated for you which is forwarding to your local API:</p>

<p><img src="/images/vpblogimg/2026/08/Forward-Voicemails-with-Transcript-to-your-Email-using-CSharp-and-ASPNET-Core/02.png" alt="Forward Voicemails with Transcript to your Email using C# and ASP.NET Core - image 2" /></p>

<p>Now append the endpoint path /WeatherForecast to the Forwarding URL and open it in a browser tab.</p>

<p>!!!info</p>

<p>If this doesn’t work for you, comment out <code class="language-plaintext highlighter-rouge">app.UseHttpsRedirection();</code> in Program.cs and restart the application hitting <code class="language-plaintext highlighter-rouge">ctrl + c</code> and running <code class="language-plaintext highlighter-rouge">dotnet run</code> again. Alternatively, you can start ngrok with the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ngrok http https://localhost:YOUR_HTTPS_PORT <span class="nt">--host-header</span><span class="o">=</span><span class="s2">"localhost:YOUR_HTTPS_PORT"</span>
</code></pre></div></div>

<p>!!!</p>

<p>You may see a warning message from ngrok:</p>

<p><img src="/images/vpblogimg/2026/08/Forward-Voicemails-with-Transcript-to-your-Email-using-CSharp-and-ASPNET-Core/03.png" alt="Forward Voicemails with Transcript to your Email using C# and ASP.NET Core - image 3" /></p>

<p>Click Visit Site to proceed.</p>

<p>You should see similar results to before, but now via ngrok’s public forwarding URL. This means that your API is publicly accessible and Twilio can send HTTP requests to it.</p>

<h2 id="receive-incoming-calls">Receive Incoming Calls</h2>

<p>Twilio provides libraries to make it easier to build Twilio applications. You will use two of those in this tutorial: The <a href="https://www.twilio.com/docs/libraries/csharp-dotnet">Twilio .NET SDK</a> and the <a href="https://github.com/twilio-labs/twilio-aspnet">helper library for ASP.NET</a>. You’ll use the SDK to generate TwiML and the helper library to respond to webhook requests.</p>

<p>Back in the terminal where your app is running, stop the application using <code class="language-plaintext highlighter-rouge">ctrl + c</code> and add the SDK and helper library for ASP.NET Core via NuGet:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package Twilio
dotnet add package Twilio.AspNet.Core
</code></pre></div></div>

<p>Open the project in your IDE and add a new file in the Controllers folder called IncomingCallController.cs. Update the controller with the code below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Mvc</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.AspNet.Core</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.TwiML</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">VoicemailForwarderWebApi.Controllers</span><span class="p">;</span>
<span class="p">[</span><span class="n">ApiController</span><span class="p">]</span>
<span class="p">[</span><span class="nf">Route</span><span class="p">(</span><span class="s">"[controller]"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">IncomingCallController</span> <span class="p">:</span> <span class="n">TwilioController</span>
<span class="p">{</span>
    <span class="p">[</span><span class="n">HttpPost</span><span class="p">]</span>
    <span class="k">public</span> <span class="n">TwiMLResult</span> <span class="nf">Index</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">VoiceResponse</span><span class="p">();</span>
        <span class="n">response</span><span class="p">.</span><span class="nf">Say</span><span class="p">(</span><span class="s">"So far, so good!"</span><span class="p">);</span>
        <span class="k">return</span> <span class="nf">TwiML</span><span class="p">(</span><span class="n">response</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>When Twilio sends an HTTP POST request to /IncomingCall this action will generate TwiML including the Say-verb which will instruct Twilio to say “So far, so good!” to the caller.</p>

<p>For Twilio to know where to send webhook requests, you need to update the webhook settings on your Twilio Phone Number.</p>

<p>Go to the <a href="https://www.twilio.com/console">Twilio Console</a>. Select your account, and then click Phone Numbers → Manage → Active Numbers on the left pane. (If Phone Numbers isn’t on the left pane, click on Explore Products and then on Phone Numbers.)</p>

<p>Click on the phone number you want to use for your project and scroll down to the Voice section.</p>

<p>Under the “A Call Comes In” label, set the dropdown to Webhook, the text field next to it to the ngrok Forwarding URL suffixed with the /IncomingCall path, the next dropdown to HTTP POST, and click Save. It should look like this:</p>

<p><img src="/images/vpblogimg/2026/08/Forward-Voicemails-with-Transcript-to-your-Email-using-CSharp-and-ASPNET-Core/04.png" alt="Forward Voicemails with Transcript to your Email using C# and ASP.NET Core - image 4" /></p>

<p>Now, run the application (<code class="language-plaintext highlighter-rouge">dotnet run</code>) and call your Twilio number, and you should hear the message “So far, so good” on your phone. Great job if this is working. If not, there are a couple of places where you can go to debug:</p>

<ul>
  <li>
    <p>You may see errors in the output from your .NET application in the terminal</p>
  </li>
  <li>
    <p>Check the output of the ngrok command in the other terminal, or browse to the ngrok dashboard (<a href="http://127.0.0.1:4040">http://127.0.0.1:4040</a>) where you can inspect HTTP requests and responses.</p>
  </li>
  <li>
    <p>You can find errors and call details in the Twilio Console under the Monitor tab.</p>
  </li>
</ul>

<p>Now that you verified the webhook is working, let’s update the TwiML so the caller can leave a voicemail. To do this, update the Say-verb to prompt the user to leave a message, and use the Record-verb to record the call.</p>

<p>Update your <code class="language-plaintext highlighter-rouge">Index</code> method as below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">VoiceResponse</span><span class="p">();</span>
<span class="n">response</span><span class="p">.</span><span class="nf">Say</span><span class="p">(</span><span class="s">"Hello. I'm not available at the moment. Please leave a message after the beep."</span><span class="p">);</span>
<span class="n">response</span><span class="p">.</span><span class="nf">Record</span><span class="p">(</span>
    <span class="n">timeout</span><span class="p">:</span> <span class="m">10</span><span class="p">,</span>
    <span class="n">transcribe</span><span class="p">:</span> <span class="k">true</span><span class="p">,</span>
    <span class="n">transcribeCallback</span><span class="p">:</span> <span class="k">new</span> <span class="nf">Uri</span><span class="p">(</span><span class="s">"/TranscribeCallback"</span><span class="p">,</span> <span class="n">UriKind</span><span class="p">.</span><span class="n">Absolute</span><span class="p">)</span>
<span class="p">);</span>
<span class="k">return</span> <span class="nf">TwiML</span><span class="p">(</span><span class="n">response</span><span class="p">);</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">timeout</code> attribute tells Twilio to end the recording after a number of seconds of silence has passed. The default is 5 seconds. You can change this to your liking.</p>

<p>By setting the <code class="language-plaintext highlighter-rouge">transcribe</code> attribute to <code class="language-plaintext highlighter-rouge">true</code>, you’ll instruct Twilio to transcribe the recording. Twilio will also store the transcription so you can <a href="https://www.twilio.com/docs/voice/api/recording-transcription">retrieve the transcription later via the Twilio API</a>.</p>

<p>The transcription process happens asynchronously. Twilio can send the transcript data to your application when it is ready. Use the <code class="language-plaintext highlighter-rouge">transcribeCallback</code> attribute to tell Twilio to which URL to send the transcription data when it is ready.</p>

<p>Now move on to handle the transcription webhook.</p>

<h2 id="receive-transcription-text-and-recording-info">Receive Transcription Text and Recording Info</h2>

<p>Twilio returns the transcription text and the recording URL in the transcribe callback message. So you can gather everything you need by handling the transcribe callback.</p>

<p>Add a new file under the Controllers folder named TranscribeCallbackController.cs. Update its contents as below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Mvc</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.AspNet.Core</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">VoicemailForwarderWebApi.Controllers</span><span class="p">;</span>
<span class="p">[</span><span class="n">ApiController</span><span class="p">]</span>
<span class="p">[</span><span class="nf">Route</span><span class="p">(</span><span class="s">"[controller]"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">TranscribeCallbackController</span> <span class="p">:</span> <span class="n">TwilioController</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">ILogger</span><span class="p">&lt;</span><span class="n">TranscribeCallbackController</span><span class="p">&gt;</span> <span class="n">_logger</span><span class="p">;</span>
    <span class="k">public</span> <span class="nf">TranscribeCallbackController</span><span class="p">(</span><span class="n">ILogger</span><span class="p">&lt;</span><span class="n">TranscribeCallbackController</span><span class="p">&gt;</span> <span class="n">logger</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_logger</span> <span class="p">=</span> <span class="n">logger</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="p">[</span><span class="n">HttpPost</span><span class="p">]</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">Index</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">form</span> <span class="p">=</span> <span class="k">await</span> <span class="n">Request</span><span class="p">.</span><span class="nf">ReadFormAsync</span><span class="p">();</span>
        <span class="kt">var</span> <span class="n">recordingSid</span> <span class="p">=</span> <span class="n">form</span><span class="p">[</span><span class="s">"RecordingSid"</span><span class="p">].</span><span class="nf">ToString</span><span class="p">();</span>
        <span class="kt">var</span> <span class="n">recordingUrl</span> <span class="p">=</span> <span class="n">form</span><span class="p">[</span><span class="s">"RecordingUrl"</span><span class="p">].</span><span class="nf">ToString</span><span class="p">();</span>
        <span class="kt">var</span> <span class="n">transcriptionText</span> <span class="p">=</span> <span class="n">form</span><span class="p">[</span><span class="s">"TranscriptionText"</span><span class="p">].</span><span class="nf">ToString</span><span class="p">();</span>
        <span class="kt">var</span> <span class="n">callingNumber</span> <span class="p">=</span> <span class="n">form</span><span class="p">[</span><span class="s">"From"</span><span class="p">].</span><span class="nf">ToString</span><span class="p">();</span>
        <span class="n">_logger</span><span class="p">.</span><span class="nf">LogInformation</span><span class="p">(</span><span class="s">"Transcription details -&gt; CallingNumber: [{callingNumber}] TranscriptionText: [{transcriptionText}], RecordingSid: [{recordingSid}], RecordingUrl: [{recordingUrl}]"</span><span class="p">,</span> 
            <span class="n">callingNumber</span><span class="p">,</span> <span class="n">transcriptionText</span><span class="p">,</span> <span class="n">recordingSid</span><span class="p">,</span> <span class="n">recordingUrl</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>To test your changes, restart your .NET application, then call your Twilio number again and leave a message. After a few seconds, you should see a new log line like this in your terminal:</p>

<p><img src="/images/vpblogimg/2026/08/Forward-Voicemails-with-Transcript-to-your-Email-using-CSharp-and-ASPNET-Core/05.png" alt="Forward Voicemails with Transcript to your Email using C# and ASP.NET Core - image 5" /></p>

<p>!!!warning</p>

<p>By default, Recording URLs don’t require authentication, and recordings are not encrypted. However, you can require basic authentication to access the recordings and <a href="https://www.twilio.com/docs/voice/tutorials/voice-recording-encryption">configure recordings to be encrypted</a> in the voice settings (Voice → Settings → General).</p>

<p>!!!</p>

<h2 id="download-the-call-recording">Download the call recording</h2>

<p>The next step is to get the recording audio. The audio is available in two formats: WAV and MP3. WAV files are uncompressed and have larger file sizes. Since they will be sent as attachments, in this example, you will download the MP3 version for efficiency.</p>

<p>First, you will need an <code class="language-plaintext highlighter-rouge">HttpClient</code> to download the file. Add the highlighted line below to your Program.cs file:</p>

<p>```csharp hl_lines=”3”
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddHttpClient();</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
Update TranscribeCallbackController.cs so that the constructor and the private variables look like below:

```csharp hl_lines="2 4 7"
private readonly ILogger&lt;TranscribeCallbackController&gt; _logger;
private readonly IHttpClientFactory _httpClientFactory;
public TranscribeCallbackController(ILogger&lt;TranscribeCallbackController&gt; logger, IHttpClientFactory httpClientFactory)
{
    _logger = logger;
    _httpClientFactory = httpClientFactory;
}
</code></pre></div></div>

<p>Now you can instantiate an <code class="language-plaintext highlighter-rouge">HttpClient</code> and download the file at <code class="language-plaintext highlighter-rouge">recordingUrl</code> by adding the following code to your <code class="language-plaintext highlighter-rouge">Index()</code> method in the <code class="language-plaintext highlighter-rouge">TranscribeCallbackController</code>:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">httpClient</span> <span class="p">=</span> <span class="n">_httpClientFactory</span><span class="p">.</span><span class="nf">CreateClient</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">recordingBytes</span> <span class="p">=</span> <span class="k">await</span> <span class="n">httpClient</span><span class="p">.</span><span class="nf">GetByteArrayAsync</span><span class="p">(</span><span class="s">$"</span><span class="p">{</span><span class="n">recordingUrl</span><span class="p">}</span><span class="s">.mp3"</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">recordingFilePath</span> <span class="p">=</span> <span class="s">$"</span><span class="p">{</span><span class="n">recordingUrl</span><span class="p">.</span><span class="nf">Substring</span><span class="p">(</span><span class="n">recordingUrl</span><span class="p">.</span><span class="nf">LastIndexOf</span><span class="p">(</span><span class="s">"/"</span><span class="p">)</span> <span class="p">+</span> <span class="m">1</span><span class="p">)}</span><span class="s">.mp3"</span><span class="p">;</span>
<span class="n">System</span><span class="p">.</span><span class="n">IO</span><span class="p">.</span><span class="n">File</span><span class="p">.</span><span class="nf">WriteAllBytes</span><span class="p">(</span><span class="n">recordingFilePath</span><span class="p">,</span> <span class="n">recordingBytes</span><span class="p">);</span>
</code></pre></div></div>

<p>By default, the recording URL doesn’t have a file extension. If you call the URL as is, Twilio returns the WAV version of the recording. To get the MP3 version, you need to append the .mp3 extension to the URL as shown above.</p>

<p>Restart your application, call your Twilio number again and leave a message. Once Twilio sends the transcription to your application, you should see an MP3 file appear in your project folder. You won’t actually need to save the file to disk for this tutorial, but you can do this to quickly test that it works so far.</p>

<h2 id="send-the-voicemail-via-email">Send the Voicemail via Email</h2>

<p>Now that you have the transcribed text and the call recording audio, the final step is to create an email and send these to your email address.</p>

<p>To achieve this, you’re going to use SendGrid SDK.</p>

<p>Stop the application and add the following packages via NuGet:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package SendGrid
dotnet add package SendGrid.Extensions.DependencyInjection
</code></pre></div></div>

<p>To send emails via SendGrid, you will need to use your API key and store it somewhere. You can use environment variables or a vault service, but for local development you can use <a href="https://docs.microsoft.com/en-us/aspnet/core/security/app-secrets">dotnet user secrets</a>. First, you need to initialize user secrets by running</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet user-secrets init
</code></pre></div></div>

<p>Then, create a new user secret called <code class="language-plaintext highlighter-rouge">SendGrid:ApiKey</code> and set your API key:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet user-secrets <span class="nb">set </span>SendGrid:ApiKey <span class="o">{</span>YOUR SENDGRID API KEY<span class="o">}</span>
</code></pre></div></div>

<p>Replace <code class="language-plaintext highlighter-rouge">{YOUR SENDGRID API KEY}</code> with your SendGrid API Key (see prerequisites).</p>

<p>Now apply the following code changes. First, update Program.cs as shown below:</p>

<p>```csharp hl_lines=”4”
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddHttpClient();
builder.Services.AddSendGrid(options =&gt; options.ApiKey = builder.Configuration[“SendGrid:ApiKey”]);</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
And add the following using statement to the top of the Program.cs file:

```csharp
using SendGrid.Extensions.DependencyInjection;
</code></pre></div></div>

<p>Then, update your <code class="language-plaintext highlighter-rouge">TranscribeCallbackController</code> to inject the <code class="language-plaintext highlighter-rouge">ISendGridClient</code> and store it in a private field:</p>

<p>```csharp hl_lines=”3 8 13”
private readonly ILogger<TranscribeCallbackController> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ISendGridClient _sendGridClient;
public TranscribeCallbackController(
    ILogger<TranscribeCallbackController> logger, 
    IHttpClientFactory httpClientFactory, 
    ISendGridClient sendGridClient
)
{
    _logger = logger;
    _httpClientFactory = httpClientFactory;
    _sendGridClient = sendGridClient;
}</TranscribeCallbackController></TranscribeCallbackController></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
Also, add these using statements to the top of the TranscribeCallbackController.cs file:

```csharp
using SendGrid;
using SendGrid.Helpers.Mail;
</code></pre></div></div>

<p>Finally, update the <code class="language-plaintext highlighter-rouge">Index</code> method of the <code class="language-plaintext highlighter-rouge">TranscribeCallbackController</code>  controller so that the final version looks like this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">form</span> <span class="p">=</span> <span class="k">await</span> <span class="n">Request</span><span class="p">.</span><span class="nf">ReadFormAsync</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">recordingSid</span> <span class="p">=</span> <span class="n">form</span><span class="p">[</span><span class="s">"RecordingSid"</span><span class="p">].</span><span class="nf">ToString</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">recordingUrl</span> <span class="p">=</span> <span class="n">form</span><span class="p">[</span><span class="s">"RecordingUrl"</span><span class="p">].</span><span class="nf">ToString</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">transcriptionText</span> <span class="p">=</span> <span class="n">form</span><span class="p">[</span><span class="s">"TranscriptionText"</span><span class="p">].</span><span class="nf">ToString</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">callingNumber</span> <span class="p">=</span> <span class="n">form</span><span class="p">[</span><span class="s">"From"</span><span class="p">].</span><span class="nf">ToString</span><span class="p">();</span>
<span class="n">_logger</span><span class="p">.</span><span class="nf">LogInformation</span><span class="p">(</span><span class="s">"Transcription details -&gt; CallingNumber: [{callingNumber}] TranscriptionText: [{transcriptionText}], RecordingSid: [{recordingSid}], RecordingUrl: [{recordingUrl}]"</span><span class="p">,</span> 
    <span class="n">callingNumber</span><span class="p">,</span> <span class="n">transcriptionText</span><span class="p">,</span> <span class="n">recordingSid</span><span class="p">,</span> <span class="n">recordingUrl</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">httpClient</span> <span class="p">=</span> <span class="n">_httpClientFactory</span><span class="p">.</span><span class="nf">CreateClient</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">recordingBytes</span> <span class="p">=</span> <span class="k">await</span> <span class="n">httpClient</span><span class="p">.</span><span class="nf">GetByteArrayAsync</span><span class="p">(</span><span class="s">$"</span><span class="p">{</span><span class="n">recordingUrl</span><span class="p">}</span><span class="s">.mp3"</span><span class="p">);</span>
<span class="kt">var</span> <span class="k">from</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">EmailAddress</span><span class="p">(</span><span class="s">"{your sender email}"</span><span class="p">,</span> <span class="s">"{your sender display name}"</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">to</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">EmailAddress</span><span class="p">(</span><span class="s">"{your recipient email}"</span><span class="p">,</span> <span class="s">"{your recipient display name}"</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">subject</span> <span class="p">=</span> <span class="s">"You've got voicemail!"</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">plainTextContent</span> <span class="p">=</span> <span class="s">$"Calling Number: </span><span class="p">{</span><span class="n">callingNumber</span><span class="p">}{</span><span class="n">Environment</span><span class="p">.</span><span class="n">NewLine</span><span class="p">}</span><span class="s">Transcription: </span><span class="p">{</span><span class="n">transcriptionText</span><span class="p">}</span><span class="s">"</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">htmlContent</span> <span class="p">=</span> <span class="s">$"&lt;p&gt;Calling Number: </span><span class="p">{</span><span class="n">callingNumber</span><span class="p">}</span><span class="s">&lt;/p&gt;&lt;p&gt;Transcription: </span><span class="p">{</span><span class="n">transcriptionText</span><span class="p">}</span><span class="s">&lt;/p&gt;"</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">msg</span> <span class="p">=</span> <span class="n">MailHelper</span><span class="p">.</span><span class="nf">CreateSingleEmail</span><span class="p">(</span><span class="k">from</span><span class="p">,</span> <span class="n">to</span><span class="p">,</span> <span class="n">subject</span><span class="p">,</span> <span class="n">plainTextContent</span><span class="p">,</span> <span class="n">htmlContent</span><span class="p">);</span>
<span class="n">msg</span><span class="p">.</span><span class="nf">AddAttachment</span><span class="p">(</span>
    <span class="k">new</span> <span class="n">Attachment</span>
    <span class="p">{</span>
        <span class="n">Content</span> <span class="p">=</span> <span class="n">Convert</span><span class="p">.</span><span class="nf">ToBase64String</span><span class="p">(</span><span class="n">recordingBytes</span><span class="p">),</span>
        <span class="n">Filename</span> <span class="p">=</span> <span class="s">"voicemail.mp3"</span><span class="p">,</span>
        <span class="n">Type</span> <span class="p">=</span> <span class="s">"audio/mpeg"</span><span class="p">,</span>
        <span class="n">Disposition</span> <span class="p">=</span> <span class="s">"attachment"</span>
    <span class="p">});</span>
<span class="kt">var</span> <span class="n">sendEmailResponse</span> <span class="p">=</span> <span class="k">await</span> <span class="n">_sendGridClient</span><span class="p">.</span><span class="nf">SendEmailAsync</span><span class="p">(</span><span class="n">msg</span><span class="p">);</span>
<span class="n">_logger</span><span class="p">.</span><span class="nf">LogInformation</span><span class="p">(</span><span class="n">sendEmailResponse</span><span class="p">.</span><span class="n">IsSuccessStatusCode</span> <span class="p">?</span> <span class="s">"Email queued successfully!"</span> <span class="p">:</span> <span class="s">"Something went wrong!"</span><span class="p">);</span>
</code></pre></div></div>

<p>!!!warning</p>

<p>Since you don’t know what’s in the recording and the transcription text, you should assume it could contain personal information. I am logging the transcription text for debugging purposes, but you should avoid doing so in production to protect PII.</p>

<p>!!!</p>

<p>Before running the application, replace <code class="language-plaintext highlighter-rouge">{your verified sender email}</code>, <code class="language-plaintext highlighter-rouge">{your sender display name}</code>, <code class="language-plaintext highlighter-rouge">{your recipient email}</code> and <code class="language-plaintext highlighter-rouge">{your recipient display name}</code> with actual values. Display names can be anything you choose. The sender email address needs to be a verified sender in SendGrid (see prerequisites).</p>

<p>Start your application again as before:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet run
</code></pre></div></div>

<p>The code above creates a new email with the MP3 file as an attachment, and the phone number and transcribed text as the body of the email.</p>

<p>Test your application one last time by calling your Twilio number and leaving a voice message. After a few seconds, you should receive an email that looks like this:</p>

<p><img src="/images/vpblogimg/2026/08/Forward-Voicemails-with-Transcript-to-your-Email-using-CSharp-and-ASPNET-Core/06.png" alt="Forward Voicemails with Transcript to your Email using C# and ASP.NET Core - image 6" /></p>

<h2 id="conclusion">Conclusion</h2>

<p>The email you send may not look pretty, but it does the job. You can use SendGrid Dynamic Email Templates and create beautiful HTML email templates. If you are interested in sending templated emails with SendGrid, take a look at these articles:</p>

<ul>
  <li>
    <p><a href="https://www.twilio.com/blog/send-rss-feed-digest-email-with-csharp-and-dynamic-email-templates">How to send RSS feed digest email with C# and SendGrid Dynamic Email Templates</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/send-emails-with-csharp-handlebars-templating-and-dynamic-email-templates">Send Emails with C#, Handlebars templating, and Dynamic Email Templates</a></p>
  </li>
</ul>

<p>To find out more about using voicemails with Twilio, tunneling, and ngrok, here are some of the articles to read:</p>

<ul>
  <li>
    <p><a href="https://www.twilio.com/blog/handle-no-answer-scenarios-voicemail-callback">How to Handle No-Answer/Pickup Scenarios with Voicemail and Callback using Twilio Voice</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/using-ngrok-2022">Using Ngrok in 2022</a></p>
  </li>
</ul>
]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS]]></title>
    <link href="https://volkanpaksoy.com/archive/2026/08/05/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/"/>
    <updated>2026-08-05T12:25:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2026/08/05/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS</id>
    <content type="html"><![CDATA[<blockquote>
  <p>This article was originally published on the <a href="https://www.twilio.com/en-us/blog/developers/community/create-an-sms-chatbot-using-csharp-amazon-lex-and-twilio-sms">Twilio Blog</a>.</p>
</blockquote>

<p>There is a tough competition in the business world to acquire and retain customers. One key step to achieve this goal is to keep your customers happy with your customer support. Having an automated chatbot helps your business provide a faster and more accessible customer support to your customers. In this article, you will learn how to build a chatbot using C#, AWS Lambda, Amazon Lex, Amazon DynamoDB, and Twilio SMS.</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>You’ll need the following things in this tutorial:</p>

<ul>
  <li>
    <p>A free <a href="https://www.twilio.com/try-twilio">Twilio account</a></p>
  </li>
  <li>
    <p>A <a href="https://support.twilio.com/hc/en-us/articles/223135247-How-to-Search-for-and-Buy-a-Twilio-Phone-Number-from-Console">Twilio Phone Number</a> with SMS capability</p>
  </li>
  <li>
    <p>A free <a href="https://aws.amazon.com/free/">AWS account</a></p>
  </li>
  <li>
    <p><a href="https://aws.amazon.com/cli/">AWS CLI</a></p>
  </li>
  <li>
    <p>An OS that supports .NET (Windows/macOS/Linux)</p>
  </li>
  <li>
    <p><a href="https://dotnet.microsoft.com/en-us/download/dotnet/6.0">.NET 6.0 SDK</a> (As of this writing the latest .NET version AWS Lambda supports is .NET 6)</p>
  </li>
  <li>
    <p>A code editor or IDE (Recommended: <a href="https://code.visualstudio.com/Download">Visual Studio Code</a> with <a href="https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp">the C# plugin</a>, <a href="https://visualstudio.microsoft.com/">Visual Studio</a>, or <a href="https://www.jetbrains.com/rider/">JetBrains Rider</a>)</p>
  </li>
  <li>
    <p><a href="https://git-scm.com/downloads">Git CLI</a></p>
  </li>
  <li>
    <p>Bash shell</p>
  </li>
</ul>

<h2 id="what-is-amazon-lex">What is Amazon Lex?</h2>

<p>Amazon Lex is an artificial intelligence service that allows developers to create voice or text-based conversational interfaces. This service powers Amazon’s own Alexa.</p>

<p>Lex provides automatic speech recognition and natural language understanding technologies. It takes the user’s input, runs it through a Natural Language Processing (NLP) engine and determines the user’s intent. The value of this is the user does not need to remember a set of commands to interact with your bot. They can talk to the bot just like they would to a human being.</p>

<p>This project uses several AWS services: Lex, Lambda and DynamoDB. To follow along, you will need an IAM user setup in your development environment. Proceed to the next section for the IAM setup. If you already have it configured, you can skip the next section and move on to the Project Overview.</p>

<p>If you created a new AWS account, this project shouldn’t cost you anything, as all these services have free tiers. If you are on an older account, it shouldn’t cost too much. Still, I recommend checking the pricing pages of the services anyway: <a href="https://aws.amazon.com/lex/pricing/">Amazon Lex Pricing</a>, <a href="https://aws.amazon.com/lambda/pricing/">AWS Lambda Pricing</a> and <a href="https://aws.amazon.com/dynamodb/pricing/">Amazon DynamoDB pricing</a>.</p>

<h2 id="set-up-aws-iam-user">Set up AWS IAM User</h2>

<p>You will need credentials to deploy your application to AWS from the command line. To create the credentials, follow the steps below:</p>

<p>First, go to the <a href="https://us-east-1.console.aws.amazon.com/iamv2/home#/users">AWS IAM Users Dashboard</a> and click the Add users button.</p>

<p>Enter the user name, such as twilio-webhook-user and tick the Access key - Programmatic access checkbox:</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/01.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 1" /></p>

<p>Click the Next: Permissions button at the bottom right.</p>

<p>Then, select Attach existing policies directly and select AdministratorAccess:</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/02.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 2" /></p>

<p>Click the Next: Tags button at the bottom right. Tags are optional (and quite valuable information), and it’s a good practice to add descriptive tags to the resources you create. Since this is a demo project, you can skip this step and click the Next: Review button at the bottom.</p>

<p>Confirm your selection on the review page. It should look like this:</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/03.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 3" /></p>

<p>Then, click the Create user button.</p>

<p>In the final step of the user creation process, you should see your credentials for the first and the last time.</p>

<p>!!!warning</p>

<p>Take note of your Access key ID and Secret access key before you press the close button.</p>

<p>!!!</p>

<p>Now, open a terminal window and run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws configure
</code></pre></div></div>

<p>You should see a prompt for AWS Access Key ID. Copy and paste your access key ID and press enter.</p>

<p>Then, copy and paste your secret access key and press enter.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/04.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 4" /></p>

<p>When prompted, type us-east-1 as the default region name and press enter.</p>

<p>!!!info</p>

<p>In this example, I will use the us-east-1 region. Regions are geographical locations where AWS have their data centers. It is a good practice to deploy as close to your customers as possible for production deployments to reduce latency. Since this is a demo project, you can use us-east-1 for convenience as it’s the default region in AWS Management Console. You can find more on AWS regions in this document: <a href="https://aws.amazon.com/about-aws/global-infrastructure/regions_az/">Regions and Availability Zones</a>.</p>

<p>!!!</p>

<p>As the default output format, type json and press enter.</p>

<p>To confirm you have configured your AWS profile correctly, run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws configure list
</code></pre></div></div>

<p>The output should look like this:</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/05.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 5" /></p>

<p>Now that you have set up your AWS credentials, you can move on to the demo project.</p>

<h2 id="project-overview">Project Overview</h2>

<p>The application you will implement is an imaginary online stock broker customer service. It will accept requests like buy, sell, show portfolio, etc.</p>

<p>Take a look at this diagram of the application flow:</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/06.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 6" /></p>

<ul>
  <li>
    <p>Customer sends an SMS to customer service (which is a Twilio Phone Number).</p>
  </li>
  <li>
    <p>Twilio receives the message and invokes the Amazon Lex callback.</p>
  </li>
  <li>
    <p>Amazon Lex identifies the user’s intent and calls the corresponding Lambda function.</p>
  </li>
  <li>
    <p>The Lambda function executes the application logic based on the request, gets the customer info from the DynamoDB database, and prepares the response.</p>
  </li>
  <li>
    <p>Lex sends the response to Twilio using the Twilio SMS integration.</p>
  </li>
  <li>
    <p>Twilio delivers the response message to the customer’s phone.</p>
  </li>
</ul>

<p>Without further ado, let’s get the demo application and start exploring the existing code.</p>

<h2 id="set-up-the-demo-project">Set up the Demo Project</h2>

<p>The focus of this article is developing a chatbot using Amazon Lex and Twilio SMS. To save time, the fundamental business logic of the fictional stock broker is implemented in the starter project.</p>

<p>Clone the project to get started:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/cloudinternals/amazon-lex-stock-broker-bot-with-twilio-sms.git <span class="nt">--branch</span> starter-project
</code></pre></div></div>

<p>Open the solution (src/StockBrokerBot/StockBrokerBot.sln) in your IDE and take a look at the project structure. The <code class="language-plaintext highlighter-rouge">StockBrokerBot.Core</code> project looks like this:</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/07.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 7" /></p>

<p>The main functionality is in 2 services: <code class="language-plaintext highlighter-rouge">IPortfolioService</code> and <code class="language-plaintext highlighter-rouge">IStockMarketService</code>.</p>

<p><code class="language-plaintext highlighter-rouge">IPortfolioService</code> shows the behaviour of the service:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">interface</span> <span class="nc">IPortfolioService</span>
<span class="p">{</span>
    <span class="n">Task</span><span class="p">&lt;</span><span class="n">UserPortfolio</span><span class="p">&gt;</span> <span class="nf">GetUserPortfolio</span><span class="p">(</span><span class="kt">string</span> <span class="n">userId</span><span class="p">);</span>
    <span class="n">Task</span><span class="p">&lt;</span><span class="n">UserPortfolio</span><span class="p">&gt;</span> <span class="nf">BuyStocks</span><span class="p">(</span><span class="kt">string</span> <span class="n">userId</span><span class="p">,</span> <span class="kt">string</span> <span class="n">stockName</span><span class="p">,</span> <span class="kt">decimal</span> <span class="n">numberOfShares</span><span class="p">);</span>
    <span class="n">Task</span><span class="p">&lt;</span><span class="n">UserPortfolio</span><span class="p">&gt;</span> <span class="nf">SellStocks</span><span class="p">(</span><span class="kt">string</span> <span class="n">userId</span><span class="p">,</span> <span class="kt">string</span> <span class="n">stockName</span><span class="p">,</span> <span class="kt">decimal</span> <span class="n">numberOfShares</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>It supports 3 operations: Get portfolio, buy stocks, and sell stocks.</p>

<p>The core library includes one implementation of the portfolio service called <code class="language-plaintext highlighter-rouge">PortfolioService</code>. It depends on a stock market service and a portfolio data provider:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">readonly</span> <span class="n">IStockMarketService</span> <span class="n">_stockMarketService</span><span class="p">;</span>
<span class="k">private</span> <span class="k">readonly</span> <span class="n">IPortfolioDataProvider</span> <span class="n">_dataProvider</span><span class="p">;</span>
<span class="k">public</span> <span class="nf">PortfolioService</span><span class="p">(</span><span class="n">IStockMarketService</span> <span class="n">stockMarketService</span><span class="p">,</span> <span class="n">IPortfolioDataProvider</span> <span class="n">dataProvider</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">_stockMarketService</span> <span class="p">=</span> <span class="n">stockMarketService</span><span class="p">;</span>
    <span class="n">_dataProvider</span> <span class="p">=</span> <span class="n">dataProvider</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">PortfolioService</code> performs validations during buy and sell operations (user has sufficient funds to buy, or shares available to sell, etc.)</p>

<p><code class="language-plaintext highlighter-rouge">IStockMarketService</code> is responsible for fetching the current stock price:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">interface</span> <span class="nc">IStockMarketService</span>
<span class="p">{</span>
    <span class="n">Task</span><span class="p">&lt;</span><span class="kt">decimal</span><span class="p">&gt;</span> <span class="nf">GetStockPrice</span><span class="p">(</span><span class="kt">string</span> <span class="n">stockName</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>There are 2 different stock market service implementations: <code class="language-plaintext highlighter-rouge">StockMarketService</code>, and <code class="language-plaintext highlighter-rouge">FluctuatingStockMarketService</code>.</p>

<p><code class="language-plaintext highlighter-rouge">StockMarketService</code> simply fetches the stock price from its data provider. <code class="language-plaintext highlighter-rouge">FluctuatingStockMarketService</code> is meant to “spice things up” a little bit. It calculates a random price by adding a small price swing within 2%. You can, of course, change this rate to create higher swings. This way, you will get a new price every time. So you can buy low and sell high and make some imaginary profits!</p>

<p>The starter project also includes a demo console application. The demo application uses JSON files to persist user portfolios and stock prices. In the actual chatbot, you will use DynamoDB tables. The demo project uses the <code class="language-plaintext highlighter-rouge">FluctuatingStockMarketService</code>. You can replace it with <code class="language-plaintext highlighter-rouge">StockMarketService</code> to get more consistent results.</p>

<p><code class="language-plaintext highlighter-rouge">UserPortfolio</code> and <code class="language-plaintext highlighter-rouge">Stock</code> entities are already annotated to be used as DynamoDB entities:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="nf">DynamoDBTable</span><span class="p">(</span><span class="s">"user-portfolio"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">UserPortfolio</span>
<span class="p">{</span>
    <span class="p">[</span><span class="n">DynamoDBHashKey</span><span class="p">]</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="n">UserId</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
</code></pre></div></div>

<p>!!!info</p>

<p>In a real project, I wouldn’t recommend creating a dependency on a storage provider from your business library but for the sake of brevity, the same entities will be used in this project.</p>

<p>!!!</p>

<p>Open a terminal, navigate to the demo project, and run the application by running:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd </span>StockBrokerBot.Demo
dotnet run
</code></pre></div></div>

<p>You should see the results that look like this:</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/08.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 8" /></p>

<p>Take some time to look into the core library and the consuming demo application. There is also a Lambda function project in the solution called <code class="language-plaintext highlighter-rouge">StockBrokerBot.ChatbotLambda</code>, but it’s empty at the moment. You will implement it while following this article.</p>

<p>When you are more familiar with the project, move on to the next section to create the chatbot.</p>

<h2 id="create-the-chatbot-with-amazon-lex">Create the Chatbot with Amazon Lex</h2>

<p>To start implementing your bot, go to <a href="https://us-east-1.console.aws.amazon.com/lexv2/home?region=us-east-1">Lex Console</a> and click Create bot.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/09.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 9" /></p>

<p>In the settings, leave the Create a blank bot option selected.</p>

<p>In the Bot name field, enter StockBrokerBot.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/10.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 10" /></p>

<p>In IAM permissions, select Create a role with basic Amazon Lex permissions.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/11.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 11" /></p>

<p>Select No in the COPPA section and click Next.</p>

<p>In the Add languages step, leave the default language (English (US)). You can choose to support multiple languages and even assign a different voice to each language. In this article, you will use a single language and text interaction only. Click the Voice interaction dropdown, scroll to the bottom and select None. This is only a text-based application option.</p>

<p>Click Done to create your bot.</p>

<p>Your bot has now been created, and Lex redirects you to create your first intent. An intent is an action your bot takes to fulfil a user’s request.</p>

<p>In the Intent name field, enter CheckStockPrice.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/12.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 12" /></p>

<p>Leave Contexts blank and scroll to Sample utterances.</p>

<p>An utterance is a phrase that corresponds to this intent. In conversations, we use many different phrases to express the same thing. For example, if you have a Hello intent, sample utterances can be “Hello”, “Hi”, “Hey” etc.</p>

<p>Click Plain Text and paste the following utterances in the field:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>check the {stockName} price
check {stockName}
how much is {stockName} ?
what is the price of {stockName} ?
get the price of {stockName}
{stockName}
price {stockName}
what is the current {stockName} price?
check the current price of {stockName}
price of {stockName} ?
</code></pre></div></div>

<p>In the above text block, you can see many occurrences of {stockName}. This is what is called a slot. It’s essentially a placeholder for a piece of data you need Lex to extract for you and pass it on to your code. If you recall the core library introduced earlier in the article, <code class="language-plaintext highlighter-rouge">GetStockPrice</code> method requires the stock name. A user might express their intention in a lot of different ways. Extracting this data is Lex’s responsibility so that, as the bot developer, you can focus on your bot’s business logic.</p>

<p>!!!warning</p>

<p>The space between the slot and the question mark at the end is intentional. Lex requires spaces surrounding the slots. If you remove those spaces, you will get an error while saving the intent.</p>

<p>!!!</p>

<p>Scroll down to the Slots section.</p>

<p>As discussed above, you’re using a slot in your utterances, but it’s not defined yet. Lex needs to know the type and whether or not it’s mandatory. Lex performs much better if you train what kind of data it’s looking for. In the stock name example, there is a finite set of company names, so you will create your own data type to train the model better. Skip adding a slot for now. You’ll revisit this part very soon.</p>

<p>Click the Save intent button and the Back to intents list link on the left pane.
<img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/13.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 13" /></p>

<p>!!!info</p>

<p>FallbackIntent is one of the built-in intents. If the user’s request doesn’t match any of the intents, FallbackIntent is invoked. You can read more about <a href="https://docs.aws.amazon.com/lexv2/latest/dg/howitworks-builtins-intents.html">Lex’s built-in intents here</a>.</p>

<p>!!!</p>

<p>On the left menu, click Slot types which is right under Intents.</p>

<p>Click Add slot type and select Add blank slot type.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/14.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 14" /></p>

<p>Enter StockName in the Slot type name field.</p>

<p>In the Slot value resolution, leave the Expand values option selected. You can also choose to restrict the slot values, but then you will need to enter every stock name that your service supports. It almost becomes a lookup table. Lex is smart enough to identify similar values based on your training set. The more comprehensive your training set is, the better results you will get.</p>

<p>Enter Apple, Alphabet, Microsoft, Tesla, and Twilio as stock names and click Save slot type.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/15.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 15" /></p>

<p>Click the Slot types link on the left, then Intents, and finally, click on the CheckStockPrice intent to get back to intent settings.</p>

<p>Scroll down to the Slots section and click Add slot.</p>

<p>Leave Required for this intent checkbox ticked.</p>

<p>Enter stockName in the Name field and select StockName in the Slot type list.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/16.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 16" /></p>

<p>In the Prompts field, enter What is the name of the stock?</p>

<p>This is a very useful feature. You don’t have to worry about asking the user for the stock name if it’s missing. Lex will automatically ask the user and fill in the missing values, so you can rest assured that it will always deliver the required values to your bot’s backend. You’ll see this in practice while testing the bot later on.</p>

<p>Click Add to close the dialog and then click Save Intent.</p>

<p>Now focus on the top of the screen.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/17.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 17" /></p>

<p>You should see the version you’re looking at (Draft version), the language (English (US)) and a label next to it that says “Not built”.</p>

<p>Click the Build button for Lex to build the machine learning (ML) model for your bot. You cannot test your bot without creating the ML model first. When the build is complete, you will see a notification on your screen.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/18.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 18" /></p>

<p>Click the Test button.</p>

<p>In the bottom field (with the Type a message placeholder), enter “What is the price of Apple?” and press enter.</p>

<p>You should see a message that says “Intent CheckStockPrice is fulfilled”</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/19.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 19" /></p>

<p>Click the Inspect button to see more details. You can see the stockName slot has Apple as the value. If you recall, you made the stockName a required slot. To test what happens if a user enters a message without providing sufficient information, enter “price” as the message and press enter.</p>

<p>!!!info</p>

<p>Note the title of the dialog says “Test Draft version”. When you test, you test the entire model for the selected language, not a single intent, even if you open the dialog while you are on an intent page.</p>

<p>!!!</p>

<p>You should see Lex now asks the name of the stock explicitly. The question it asks is the prompt message you entered when you created the slot type.</p>

<p>Just to emphasise how it works, it’s not directly looking up the utterances and matching strings to determine the intent. For example, you can express the same intent by entering “show the price of Tesla stock”, and you should still see the intent is fulfilled message even though it’s not part of the utterance list. If the expected intent is not fulfilled, you can modify your utterances, rebuild, and retest the model.</p>

<p>!!!warning</p>

<p>Before you test, make sure to build your model if you’ve made any changes. Otherwise, you’d be testing the previous model.</p>

<p>!!!</p>

<p>After the intent is recognized, what Lex will do with it is determined by the Fulfillment settings. By default, fulfilment is not active. Scroll down to the Fulfillment section and click the Active radio button. Lex invokes the Lambda function associated with your bot by default. You can confirm this behaviour by expanding the parameters and clicking the Advanced options button.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/20.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 20" /></p>

<p>Set the Fulfilment to active and ensure the Use a Lambda function for fulfilment option is ticked.</p>

<p>Click Save Intent and then click the Back to intents list link on the left.</p>

<p>Click the Add Intent button. It will show you two options: Add empty intent and Use built-in intent)</p>

<p>Select Add empty intent.</p>

<p>Enter GetPortfolio as intent name in the dialog and click Add.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/21.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 21" /></p>

<p>In the Sample utterances section, switch to Plain Text view and paste the following utterances:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>​​get portfolio
show my portfolio
my portfolio
my stocks
show me the money!
</code></pre></div></div>

<p>In the Fulfillment section, set the Active option to true.</p>

<p>Click the Save intent button and Back to intents list link on the left.</p>

<p>Click Add intent again and set the intent name to BuyStocks. Update the utterances with the ones below as you did before:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>buy {numberOfShares} of {stockName} 
buy shares {numberOfShares} ,stock {stockName}
buy {numberOfShares} {stockName}
purchase {numberOfShares} of {stockName} stock
get {numberOfShares} shares of {stockName}
buy {stockName} {numberOfShares}  shares
buy {numberOfShares} shares of {stockName}
buy {numberOfShares} shares of {stockName} stock
</code></pre></div></div>

<p>In the Slots section, click the Add slot button.</p>

<p>In the Add slot dialog, set Required for this intent to true, enter stockName as the name, select StockName as slot type and “What is the name of the stock?” as the prompt.</p>

<p>Click Add.</p>

<p>Click the Add slot button again.</p>

<p>This time, set the name to numberOfShares, slot type to AMAZON.Number and the prompt to “How many shares?”.</p>

<p>Click Add again to save the second slot.</p>

<p>In the Fulfilment section, set the Active option to true.</p>

<p>Click the Save intent button and Back to intents list link on the left.</p>

<p>Click Add intent for one last time and set the intent name to SellStocks. Update the utterances with the ones below as you did before:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sell {numberOfShares} shares of {stockName}
sell shares, number: {numberOfShares} , stock: {stockName}
sell {numberOfShares} of {stockName}
</code></pre></div></div>

<p>SellStocks intent is very similar to the BuyStocks intent. Create the same slot types as the BuyStocks intent as described above.</p>

<p>In the Fulfilment section, set the Active option to true.</p>

<p>Click the Save Intent button.</p>

<p>Now that all the intents have been described, click the Build button to rebuild the model.</p>

<p>After a show while, you should get a Successfully built notification:</p>

<p>Dismiss the notification and click the Bot: StockBrokerBot link in the breadcrumb.</p>

<p>In the left menu, under your bot there is a Bot versions link, and under it the Draft version which you’ve been working on.</p>

<p>Click Bot versions, and then click Create version.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/22.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 22" /></p>

<p>In the Description field, enter a description such as “Initial version with four intents” and click Create button at the bottom of the page.</p>

<p>You don’t assign version numbers, they are auto-incrementing integers. After you’ve created the version, it should appear in the version list:</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/23.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 23" /></p>

<p>A version is essentially a read-only snapshot of your bot. You cannot modify a version after you’ve published it.</p>

<p>Now, take a look at another important concept: Aliases.</p>

<p>Click Aliases link on the left menu. It should show the default TestBotAlias:</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/24.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 24" /></p>

<p>!!!info</p>

<p>An alias is associated with a specific version of your bot. The benefit of this is you can have multiple aliases such as test and live. If you publish a new version, you can point the test alias to the new version. This way your live alias is not affected until you test your changes. After you’re satisfied your new version is ready to go live, you can simply associate the live alias with the new version and all the new requests will come to the new version of your bot. Also, if you experience issues with your latest version, you can simply assign the previous version to your alias to roll back. This kind of separation between the versions and aliases makes change management a lot easier.</p>

<p>!!!</p>

<p>Click the Create alias button.</p>

<p>Enter Live as Alias name.</p>

<p>In the Associate with a version section, choose Version 1. The language comes already enabled so leave it like that.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/25.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 25" /></p>

<p>Click the Create button.</p>

<p>You should see the alias is successfully created and shown in the list:</p>

<p>Now Version 1 of your bot has been published.</p>

<p>You will assign a Lambda function to your bot, but first, move on to the next section to create the backend of your bot.</p>

<h2 id="create-the-backend">Create the Backend</h2>

<p>Open a terminal and navigate to the directory that will be the root of your project.</p>

<p>You will need the Amazon Lambda Tools .NET tool to deploy the function via the command line. You can install it by running the command below:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet tool <span class="nb">install</span> <span class="nt">-g</span> Amazon.Lambda.Tools
</code></pre></div></div>

<p>As shown in the demo project, you will need two data sources: one to store users’ portfolios and the other to store stock prices. (The following scripts can be found in the Setup/InfrastructureSetup.sh file in the <code class="language-plaintext highlighter-rouge">ChatbotLambda</code> project.)</p>

<p>To create the user portfolio table, run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws dynamodb create-table <span class="se">\</span>
    <span class="nt">--table-name</span> user-portfolio <span class="se">\</span>
    <span class="nt">--attribute-definitions</span> <span class="se">\</span>
        <span class="nv">AttributeName</span><span class="o">=</span>UserId,AttributeType<span class="o">=</span>S <span class="se">\</span>
    <span class="nt">--key-schema</span> <span class="se">\</span>
        <span class="nv">AttributeName</span><span class="o">=</span>UserId,KeyType<span class="o">=</span>HASH <span class="se">\</span>
    <span class="nt">--provisioned-throughput</span> <span class="se">\</span>
        <span class="nv">ReadCapacityUnits</span><span class="o">=</span>5,WriteCapacityUnits<span class="o">=</span>5 <span class="se">\</span>
    <span class="nt">--table-class</span> STANDARD
</code></pre></div></div>

<p>The script above creates a DynamoDB table with the UserId partition key, which you will use to query and fetch the users’ records.</p>

<p>To keep things simple, the account creation process is omitted. To create the account, add your user’s portfolio directly to the database by running the following command (replace <code class="language-plaintext highlighter-rouge">{ YOUR PHONE NUMBER WITH COUNTRY CODE }</code> with your actual phone number before you run):</p>

<p>!!!warning</p>

<p>This phone number will be used to identify the user, so it must match the number sent by Twilio. It will be sent in the SessionId field, and it will not start with a “+”. So, for example, if your country code is 1, enter the number as 17407593063, without the leading plus sign.</p>

<p>!!!</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws dynamodb put-item <span class="se">\</span>
    <span class="nt">--table-name</span> user-portfolio <span class="se">\</span>
    <span class="nt">--item</span> <span class="se">\</span>
      <span class="s1">'{"UserId": {"S": "{ YOUR PHONE NUMBER WITH COUNTRY CODE }"}, "AvailableCash": {"N": "1000"}, "StockPortfolio": {"L": []}}'</span>
</code></pre></div></div>

<p>This scripts creates you an account with no stocks and $1000 available cash.</p>

<p>Since the customers will come to your chatbot via SMS, <code class="language-plaintext highlighter-rouge">UserId</code> is used as the unique customer id. In a more complex scenario, you would have a different unique id to identify users. Twilio sends the phone number with the county code so you create your record in the same format for convenience.</p>

<p>Similarly, to create the stock prices table, run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws dynamodb create-table <span class="se">\</span>
     <span class="nt">--table-name</span> stock-prices <span class="se">\</span>
     <span class="nt">--attribute-definitions</span> <span class="se">\</span>
         <span class="nv">AttributeName</span><span class="o">=</span>Name,AttributeType<span class="o">=</span>S <span class="se">\</span>
     <span class="nt">--key-schema</span> <span class="se">\</span>
         <span class="nv">AttributeName</span><span class="o">=</span>Name,KeyType<span class="o">=</span>HASH <span class="se">\</span>
     <span class="nt">--provisioned-throughput</span> <span class="se">\</span>
         <span class="nv">ReadCapacityUnits</span><span class="o">=</span>5,WriteCapacityUnits<span class="o">=</span>5 <span class="se">\</span>
     <span class="nt">--table-class</span> STANDARD
</code></pre></div></div>

<p>Then, run the following to add some stock prices:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws dynamodb put-item <span class="nt">--table-name</span> stock-prices <span class="nt">--item</span> <span class="s1">'{"Name": {"S": "Apple"}, "Price": {"N": "144.00"} }'</span>
aws dynamodb put-item <span class="nt">--table-name</span> stock-prices <span class="nt">--item</span> <span class="s1">'{"Name": {"S": "Alphabet"}, "Price": {"N": "96.00"} }'</span>
aws dynamodb put-item <span class="nt">--table-name</span> stock-prices <span class="nt">--item</span> <span class="s1">'{"Name": {"S": "Microsoft"}, "Price": {"N": "144.00"} }'</span>
aws dynamodb put-item <span class="nt">--table-name</span> stock-prices <span class="nt">--item</span> <span class="s1">'{"Name": {"S": "Tesla"}, "Price": {"N": "182.00"} }'</span>
aws dynamodb put-item <span class="nt">--table-name</span> stock-prices <span class="nt">--item</span> <span class="s1">'{"Name": {"S": "Twilio"}, "Price": {"N": "46.00"} }'</span>
</code></pre></div></div>

<p>Now that the database is ready, prepare the IAM roles and policies that your Lambda function will need. The easiest way to set those up is to run the following commands when you’re in the root of the cloned project:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd </span>src/StockBrokerBot/StockBrokerBot.ChatbotLambda/Setup/
aws iam create-role <span class="nt">--role-name</span> stockbrokerbot-lambda-role <span class="nt">--assume-role-policy-document</span> file://LambdaBasicRole.json
aws iam attach-role-policy <span class="nt">--role-name</span> stockbrokerbot-lambda-role <span class="nt">--policy-arn</span> arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam put-role-policy <span class="nt">--role-name</span> stockbrokerbot-lambda-role <span class="nt">--policy-name</span> dynamodb-table-access <span class="nt">--policy-document</span> file://LambdaDynamoDBAccessPolicy.json
</code></pre></div></div>

<p>LambdaBasicRole.json contains the role for the Lambda function by assuming Lambda service role. Then you attach AWS-managed AWSLambdaBasicExecutionRole policy that grants access to CloudWatch logs. Then, you attach the custom policy specified in LambdaDynamoDBAccessPolicy.json file that grants permissions to access the two DynamoDB tables you created earlier.</p>

<p>Enough with the infrastructure stuff; now it’s time to write some code!</p>

<p>Your Lambda function will receive events from the Amazon Lex service and will use the Amazon DynamoDB service to read/write data. In your terminal, navigate to the root of the Lambda project (src/StockBrokerBot/StockBrokerBot.ChatbotLambda) and run the following commands to add the necessary NuGet packages to your project:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package Amazon.Lambda.LexV2Events
dotnet add package AWSSDK.DynamoDBv2
</code></pre></div></div>

<p>In the Lambda project, create a new directory called IntentProcessors, and under it, a file called AbstractIntentProcessor.cs and set its contents as shown below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Amazon.Lambda.Core</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Amazon.Lambda.LexV2Events</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">StockBrokerBot.ChatbotLambda.IntentProcessors</span><span class="p">;</span>
<span class="k">public</span> <span class="k">abstract</span> <span class="k">class</span> <span class="nc">AbstractIntentProcessor</span>
<span class="p">{</span>
    <span class="k">internal</span> <span class="k">const</span> <span class="kt">string</span> <span class="n">MessageContentType</span> <span class="p">=</span> <span class="s">"PlainText"</span><span class="p">;</span>
    <span class="k">internal</span> <span class="k">const</span> <span class="kt">string</span> <span class="n">IntentStateFulfilled</span> <span class="p">=</span> <span class="s">"Fulfilled"</span><span class="p">;</span>
    <span class="k">internal</span> <span class="k">const</span> <span class="kt">string</span> <span class="n">IntentStateFailed</span> <span class="p">=</span> <span class="s">"Failed"</span><span class="p">;</span>
    <span class="k">internal</span> <span class="k">const</span> <span class="kt">string</span> <span class="n">DialogActionClose</span> <span class="p">=</span> <span class="s">"Close"</span><span class="p">;</span>
    <span class="k">public</span> <span class="k">abstract</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">LexV2Response</span><span class="p">&gt;</span> <span class="nf">Process</span><span class="p">(</span><span class="n">LexV2Event</span> <span class="n">lexEvent</span><span class="p">,</span> <span class="n">ILambdaContext</span> <span class="n">context</span><span class="p">);</span>
    <span class="k">protected</span> <span class="n">LexV2Response</span> <span class="nf">Close</span><span class="p">(</span><span class="kt">string</span> <span class="n">intentName</span><span class="p">,</span> <span class="n">Dictionary</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">,</span> <span class="kt">string</span><span class="p">&gt;</span> <span class="n">sessionAttributes</span><span class="p">,</span> <span class="kt">string</span> <span class="n">fulfillmentState</span><span class="p">,</span> <span class="kt">string</span> <span class="n">responseMessage</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="k">new</span> <span class="n">LexV2Response</span>
        <span class="p">{</span>
            <span class="n">SessionState</span> <span class="p">=</span> <span class="k">new</span> <span class="n">LexV2SessionState</span>
            <span class="p">{</span>
                <span class="n">Intent</span> <span class="p">=</span> <span class="k">new</span> <span class="n">LexV2Intent</span> <span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="n">intentName</span><span class="p">,</span> <span class="n">State</span> <span class="p">=</span> <span class="n">fulfillmentState</span> <span class="p">},</span>
                <span class="n">SessionAttributes</span> <span class="p">=</span> <span class="n">sessionAttributes</span><span class="p">,</span>
                <span class="n">DialogAction</span> <span class="p">=</span> <span class="k">new</span> <span class="n">LexV2DialogAction</span>  <span class="p">{</span> <span class="n">Type</span> <span class="p">=</span> <span class="n">DialogActionClose</span> <span class="p">}</span>
            <span class="p">},</span>
            <span class="n">Messages</span> <span class="p">=</span> <span class="k">new</span> <span class="n">List</span><span class="p">&lt;</span><span class="n">LexV2Message</span><span class="p">&gt;</span>
            <span class="p">{</span>
                <span class="k">new</span><span class="p">()</span>
                <span class="p">{</span>
                    <span class="n">ContentType</span> <span class="p">=</span> <span class="n">MessageContentType</span><span class="p">,</span>
                    <span class="n">Content</span> <span class="p">=</span> <span class="n">responseMessage</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">};</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The abstract class leaves the <code class="language-plaintext highlighter-rouge">Process</code> method abstract to be implemented by the inheriting intent processors. It also contains the constants and <code class="language-plaintext highlighter-rouge">Close</code> method, which is shared among all the intent processors, so they are placed in the base class to avoid repetition.</p>

<p>In the demo application, you used two JSON-based data providers to manage user portfolio and stock price data. This approach doesn’t work with Lambda functions, as the JSON files will be gone when the function returns. Every time a new copy will be created from scratch, which doesn’t work for databases.</p>

<p>To persist data, you will need DynamoDB providers. Similar to the demo console application, create a directory named Persistence and, under it, create a new file called PortfolioDynamoDBDataProvider.cs.</p>

<p>Update the code as shown below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Amazon.DynamoDBv2</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Amazon.DynamoDBv2.DataModel</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">StockBrokerBot.Core.Entities</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">StockBrokerBot.Core.Persistence</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">StockBrokerBot.ChatbotLambda.Persistence</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">PortfolioDynamoDBDataProvider</span> <span class="p">:</span> <span class="n">IPortfolioDataProvider</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="n">AmazonDynamoDBClient</span> <span class="n">_dynamoDbClient</span><span class="p">;</span>
    <span class="k">private</span> <span class="n">DynamoDBContext</span> <span class="n">_dynamoDbContext</span><span class="p">;</span>
    <span class="k">public</span> <span class="nf">PortfolioDynamoDBDataProvider</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="n">_dynamoDbClient</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">AmazonDynamoDBClient</span><span class="p">();</span>
        <span class="n">_dynamoDbContext</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">DynamoDBContext</span><span class="p">(</span><span class="n">_dynamoDbClient</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">UserPortfolio</span><span class="p">&gt;</span> <span class="nf">GetUserPortfolio</span><span class="p">(</span><span class="kt">string</span> <span class="n">userId</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="k">await</span> <span class="n">_dynamoDbContext</span><span class="p">.</span><span class="n">LoadAsync</span><span class="p">&lt;</span><span class="n">UserPortfolio</span><span class="p">&gt;(</span><span class="n">userId</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">SaveUserPortfolio</span><span class="p">(</span><span class="n">UserPortfolio</span> <span class="n">userPortfolio</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">await</span> <span class="n">_dynamoDbContext</span><span class="p">.</span><span class="nf">SaveAsync</span><span class="p">(</span><span class="n">userPortfolio</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Create another file called StockMarketDynamoDBDataProvider.cs and update the code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Amazon.DynamoDBv2</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Amazon.DynamoDBv2.DataModel</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">StockBrokerBot.Core.Entities</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">StockBrokerBot.Core.Persistence</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">StockBrokerBot.ChatbotLambda.Persistence</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">StockMarketDynamoDBDataProvider</span> <span class="p">:</span> <span class="n">IStockMarketDataProvider</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="n">AmazonDynamoDBClient</span> <span class="n">_dynamoDbClient</span><span class="p">;</span>
    <span class="k">private</span> <span class="n">DynamoDBContext</span> <span class="n">_dynamoDbContext</span><span class="p">;</span>
    <span class="k">public</span> <span class="nf">StockMarketDynamoDBDataProvider</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="n">_dynamoDbClient</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">AmazonDynamoDBClient</span><span class="p">();</span>
        <span class="n">_dynamoDbContext</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">DynamoDBContext</span><span class="p">(</span><span class="n">_dynamoDbClient</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">decimal</span><span class="p">&gt;</span> <span class="nf">GetStockPrice</span><span class="p">(</span><span class="kt">string</span> <span class="n">name</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">stock</span> <span class="p">=</span> <span class="k">await</span> <span class="n">_dynamoDbContext</span><span class="p">.</span><span class="n">LoadAsync</span><span class="p">&lt;</span><span class="n">Stock</span><span class="p">&gt;(</span><span class="n">name</span><span class="p">);</span>
        <span class="k">return</span> <span class="n">stock</span><span class="p">.</span><span class="n">Price</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now you can implement your first concrete intent processor. Create a new file under the IntentProcessors directory called CheckStockPriceIntentProcessor.cs with the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Amazon.Lambda.Core</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Amazon.Lambda.LexV2Events</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">StockBrokerBot.ChatbotLambda.Persistence</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">StockBrokerBot.Core.Services</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">StockBrokerBot.ChatbotLambda.IntentProcessors</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">CheckStockPriceIntentProcessor</span> <span class="p">:</span> <span class="n">AbstractIntentProcessor</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">override</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">LexV2Response</span><span class="p">&gt;</span> <span class="nf">Process</span><span class="p">(</span><span class="n">LexV2Event</span> <span class="n">lexEvent</span><span class="p">,</span> <span class="n">ILambdaContext</span> <span class="n">context</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">slots</span> <span class="p">=</span> <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionState</span><span class="p">.</span><span class="n">Intent</span><span class="p">.</span><span class="n">Slots</span><span class="p">;</span>
        <span class="kt">var</span> <span class="n">requestedStockName</span> <span class="p">=</span> <span class="n">slots</span><span class="p">[</span><span class="s">"stockName"</span><span class="p">].</span><span class="n">Value</span><span class="p">.</span><span class="n">InterpretedValue</span><span class="p">;</span>
        <span class="kt">var</span> <span class="n">stockMarketService</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">FluctuatingStockMarketService</span><span class="p">(</span><span class="k">new</span> <span class="nf">StockMarketDynamoDBDataProvider</span><span class="p">());</span>
        <span class="kt">var</span> <span class="n">price</span> <span class="p">=</span> <span class="k">await</span> <span class="n">stockMarketService</span><span class="p">.</span><span class="nf">GetStockPrice</span><span class="p">(</span><span class="n">requestedStockName</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">responseMessage</span> <span class="p">=</span> <span class="s">$"Current price of </span><span class="p">{</span><span class="n">requestedStockName</span><span class="p">}</span><span class="s"> is $</span><span class="p">{</span><span class="n">price</span><span class="p">:</span><span class="n">N2</span><span class="p">}</span><span class="s">"</span><span class="p">;</span>
        <span class="k">return</span> <span class="nf">Close</span><span class="p">(</span>
            <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionState</span><span class="p">.</span><span class="n">Intent</span><span class="p">.</span><span class="n">Name</span><span class="p">,</span>
            <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionState</span><span class="p">.</span><span class="n">SessionAttributes</span><span class="p">,</span>
            <span class="n">IntentStateFulfilled</span><span class="p">,</span>
            <span class="n">responseMessage</span>
        <span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Lex sends the <code class="language-plaintext highlighter-rouge">stockName</code> in the slots dictionary. After getting that value, you pass it on to the <code class="language-plaintext highlighter-rouge">StockMarketService</code>, format the output, and send it back to Lex to deliver to the user.</p>

<p>Next, implement the get user portfolio intent. Create a file named GetPortfolioIntentProcessor.cs under the IntentProcessors directory and update the code to:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Amazon.Lambda.Core</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Amazon.Lambda.LexV2Events</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">StockBrokerBot.ChatbotLambda.Persistence</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">StockBrokerBot.Core.Services</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">StockBrokerBot.ChatbotLambda.IntentProcessors</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">GetPortfolioIntentProcessor</span> <span class="p">:</span> <span class="n">AbstractIntentProcessor</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">override</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">LexV2Response</span><span class="p">&gt;</span> <span class="nf">Process</span><span class="p">(</span><span class="n">LexV2Event</span> <span class="n">lexEvent</span><span class="p">,</span> <span class="n">ILambdaContext</span> <span class="n">context</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">userId</span> <span class="p">=</span> <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionId</span><span class="p">;</span>
        <span class="kt">var</span> <span class="n">userPortfolioService</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">PortfolioService</span><span class="p">(</span>
                <span class="k">new</span> <span class="nf">FluctuatingStockMarketService</span><span class="p">(</span><span class="k">new</span> <span class="nf">StockMarketDynamoDBDataProvider</span><span class="p">()),</span> 
                <span class="k">new</span> <span class="nf">PortfolioDynamoDBDataProvider</span><span class="p">()</span>
        <span class="p">);</span>
        <span class="kt">var</span> <span class="n">userPortfolio</span> <span class="p">=</span> <span class="k">await</span> <span class="n">userPortfolioService</span><span class="p">.</span><span class="nf">GetUserPortfolio</span><span class="p">(</span><span class="n">userId</span><span class="p">);</span>
        <span class="k">return</span> <span class="nf">Close</span><span class="p">(</span>
            <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionState</span><span class="p">.</span><span class="n">Intent</span><span class="p">.</span><span class="n">Name</span><span class="p">,</span>
            <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionState</span><span class="p">.</span><span class="n">SessionAttributes</span><span class="p">,</span>
            <span class="n">IntentStateFulfilled</span><span class="p">,</span>
            <span class="n">userPortfolio</span><span class="p">.</span><span class="nf">ToString</span><span class="p">()</span>
        <span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Twilio sends the user’s phone number to Lex and it sends it to your Lambda function in the <code class="language-plaintext highlighter-rouge">SessionId</code> field. You use it to fetch the user portfolio and send it back to the user.</p>

<p>!!!info</p>

<p>Constructing complex objects manually is not ideal. Setting up Dependency Injection is left out as it’s not the focus of this project. You can take a look at <a href="https://nodogmablog.bryanhogan.net/2022/10/simple-dependency-injection-for-net-lambda-functions/">this article</a> and implement DI as an improvement.</p>

<p>!!!</p>

<p>Next, create a new intent processor under the IntentProcessors directory called BuyStocksIntentProcessor.cs with the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Amazon.Lambda.Core</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Amazon.Lambda.LexV2Events</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">StockBrokerBot.ChatbotLambda.Persistence</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">StockBrokerBot.Core.Services</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">StockBrokerBot.ChatbotLambda.IntentProcessors</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">BuyStocksIntentProcessor</span> <span class="p">:</span> <span class="n">AbstractIntentProcessor</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">override</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">LexV2Response</span><span class="p">&gt;</span> <span class="nf">Process</span><span class="p">(</span><span class="n">LexV2Event</span> <span class="n">lexEvent</span><span class="p">,</span> <span class="n">ILambdaContext</span> <span class="n">context</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">slots</span> <span class="p">=</span> <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionState</span><span class="p">.</span><span class="n">Intent</span><span class="p">.</span><span class="n">Slots</span><span class="p">;</span>
        <span class="kt">var</span> <span class="n">requestedStockName</span> <span class="p">=</span> <span class="n">slots</span><span class="p">[</span><span class="s">"stockName"</span><span class="p">].</span><span class="n">Value</span><span class="p">.</span><span class="n">InterpretedValue</span><span class="p">;</span>
        <span class="kt">var</span> <span class="n">numberOfShares</span> <span class="p">=</span>  <span class="kt">decimal</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="n">slots</span><span class="p">[</span><span class="s">"numberOfShares"</span><span class="p">].</span><span class="n">Value</span><span class="p">.</span><span class="n">InterpretedValue</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">userId</span> <span class="p">=</span> <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionId</span><span class="p">;</span>
        <span class="kt">var</span> <span class="n">userPortfolioService</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">PortfolioService</span><span class="p">(</span><span class="k">new</span> <span class="nf">FluctuatingStockMarketService</span><span class="p">(</span><span class="k">new</span> <span class="nf">StockMarketDynamoDBDataProvider</span><span class="p">()),</span> <span class="k">new</span> <span class="nf">PortfolioDynamoDBDataProvider</span><span class="p">());</span>
        <span class="k">try</span>
        <span class="p">{</span>
            <span class="kt">var</span> <span class="n">updatedPortfolio</span> <span class="p">=</span> <span class="k">await</span> <span class="n">userPortfolioService</span><span class="p">.</span><span class="nf">BuyStocks</span><span class="p">(</span><span class="n">userId</span><span class="p">,</span> <span class="n">requestedStockName</span><span class="p">,</span> <span class="n">numberOfShares</span><span class="p">);</span>
            <span class="kt">var</span> <span class="n">responseMessage</span> <span class="p">=</span> <span class="s">$"Your request has been fulfilled. </span><span class="p">{</span><span class="n">updatedPortfolio</span><span class="p">}</span><span class="s">"</span><span class="p">;</span>
            <span class="k">return</span> <span class="nf">Close</span><span class="p">(</span>
                <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionState</span><span class="p">.</span><span class="n">Intent</span><span class="p">.</span><span class="n">Name</span><span class="p">,</span>
                <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionState</span><span class="p">.</span><span class="n">SessionAttributes</span><span class="p">,</span>
                <span class="n">IntentStateFulfilled</span><span class="p">,</span>
                <span class="n">responseMessage</span>
            <span class="p">);</span>
        <span class="p">}</span>
        <span class="k">catch</span> <span class="p">(</span><span class="n">Exception</span> <span class="n">e</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="kt">var</span> <span class="n">responseMessage</span> <span class="p">=</span> <span class="s">$"Error while buying stock: </span><span class="p">{</span><span class="n">requestedStockName</span><span class="p">}</span><span class="s">. </span><span class="p">{</span><span class="n">e</span><span class="p">.</span><span class="n">Message</span><span class="p">}</span><span class="s">. Call us at +0800 555-555 if the problem persists."</span><span class="p">;</span>
            <span class="k">return</span> <span class="nf">Close</span><span class="p">(</span>
                <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionState</span><span class="p">.</span><span class="n">Intent</span><span class="p">.</span><span class="n">Name</span><span class="p">,</span>
                <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionState</span><span class="p">.</span><span class="n">SessionAttributes</span><span class="p">,</span>
                <span class="n">IntentStateFailed</span><span class="p">,</span>
                <span class="n">responseMessage</span>
            <span class="p">);</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And implement the final intent for selling stocks by creating a new file called SellStocksIntentProcessor.cs under the IntentProcessors directory.</p>

<p>Update the code as shown below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Amazon.Lambda.Core</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Amazon.Lambda.LexV2Events</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">StockBrokerBot.ChatbotLambda.Persistence</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">StockBrokerBot.Core.Services</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">StockBrokerBot.ChatbotLambda.IntentProcessors</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">SellStocksIntentProcessor</span> <span class="p">:</span> <span class="n">AbstractIntentProcessor</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">override</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">LexV2Response</span><span class="p">&gt;</span> <span class="nf">Process</span><span class="p">(</span><span class="n">LexV2Event</span> <span class="n">lexEvent</span><span class="p">,</span> <span class="n">ILambdaContext</span> <span class="n">context</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">slots</span> <span class="p">=</span> <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionState</span><span class="p">.</span><span class="n">Intent</span><span class="p">.</span><span class="n">Slots</span><span class="p">;</span>
        <span class="kt">var</span> <span class="n">requestedStockName</span> <span class="p">=</span> <span class="n">slots</span><span class="p">[</span><span class="s">"stockName"</span><span class="p">].</span><span class="n">Value</span><span class="p">.</span><span class="n">InterpretedValue</span><span class="p">;</span>
        <span class="kt">var</span> <span class="n">numberOfShares</span> <span class="p">=</span>  <span class="kt">decimal</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="n">slots</span><span class="p">[</span><span class="s">"numberOfShares"</span><span class="p">].</span><span class="n">Value</span><span class="p">.</span><span class="n">InterpretedValue</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">userId</span> <span class="p">=</span> <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionId</span><span class="p">;</span>
        <span class="kt">var</span> <span class="n">userPortfolioService</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">PortfolioService</span><span class="p">(</span><span class="k">new</span> <span class="nf">FluctuatingStockMarketService</span><span class="p">(</span><span class="k">new</span> <span class="nf">StockMarketDynamoDBDataProvider</span><span class="p">()),</span> <span class="k">new</span> <span class="nf">PortfolioDynamoDBDataProvider</span><span class="p">());</span>
        <span class="k">try</span>
        <span class="p">{</span>
            <span class="kt">var</span> <span class="n">updatedPortfolio</span> <span class="p">=</span> <span class="k">await</span> <span class="n">userPortfolioService</span><span class="p">.</span><span class="nf">SellStocks</span><span class="p">(</span><span class="n">userId</span><span class="p">,</span> <span class="n">requestedStockName</span><span class="p">,</span> <span class="n">numberOfShares</span><span class="p">);</span>
            <span class="kt">var</span> <span class="n">responseMessage</span> <span class="p">=</span> <span class="s">$"Your request has been fulfilled. </span><span class="p">{</span><span class="n">updatedPortfolio</span><span class="p">}</span><span class="s">"</span><span class="p">;</span>
            <span class="k">return</span> <span class="nf">Close</span><span class="p">(</span>
                <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionState</span><span class="p">.</span><span class="n">Intent</span><span class="p">.</span><span class="n">Name</span><span class="p">,</span>
                <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionState</span><span class="p">.</span><span class="n">SessionAttributes</span><span class="p">,</span>
                <span class="n">IntentStateFulfilled</span><span class="p">,</span>
                <span class="n">responseMessage</span>
            <span class="p">);</span>
        <span class="p">}</span>
        <span class="k">catch</span> <span class="p">(</span><span class="n">Exception</span> <span class="n">e</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="kt">var</span> <span class="n">responseMessage</span> <span class="p">=</span> <span class="s">$"Error while selling stock: </span><span class="p">{</span><span class="n">requestedStockName</span><span class="p">}</span><span class="s">. </span><span class="p">{</span><span class="n">e</span><span class="p">.</span><span class="n">Message</span><span class="p">}</span><span class="s">. Call us at +0800 555-555 if the problem persists."</span><span class="p">;</span>
            <span class="k">return</span> <span class="nf">Close</span><span class="p">(</span>
                <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionState</span><span class="p">.</span><span class="n">Intent</span><span class="p">.</span><span class="n">Name</span><span class="p">,</span>
                <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionState</span><span class="p">.</span><span class="n">SessionAttributes</span><span class="p">,</span>
                <span class="n">IntentStateFailed</span><span class="p">,</span>
                <span class="n">responseMessage</span>
            <span class="p">);</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Most of the business logic is defined in the core library so what these intents do is to collect data from the user (via Lex and Twilio) and call the corresponding method of the services.</p>

<p>Finally, update your Function.cs as shown below to tie them all together:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Amazon.Lambda.Core</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Amazon.Lambda.LexV2Events</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">StockBrokerBot.ChatbotLambda.IntentProcessors</span><span class="p">;</span>
<span class="c1">// Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class.</span>
<span class="p">[</span><span class="n">assembly</span><span class="p">:</span> <span class="nf">LambdaSerializer</span><span class="p">(</span><span class="k">typeof</span><span class="p">(</span><span class="n">Amazon</span><span class="p">.</span><span class="n">Lambda</span><span class="p">.</span><span class="n">Serialization</span><span class="p">.</span><span class="n">SystemTextJson</span><span class="p">.</span><span class="n">DefaultLambdaJsonSerializer</span><span class="p">))]</span>
<span class="k">namespace</span> <span class="nn">StockBrokerBot.ChatbotLambda</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">Function</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">LexV2Response</span><span class="p">&gt;</span> <span class="nf">FunctionHandler</span><span class="p">(</span><span class="n">LexV2Event</span> <span class="n">lexEvent</span><span class="p">,</span> <span class="n">ILambdaContext</span> <span class="n">context</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">AbstractIntentProcessor</span> <span class="n">process</span> <span class="p">=</span> <span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionState</span><span class="p">.</span><span class="n">Intent</span><span class="p">.</span><span class="n">Name</span> <span class="k">switch</span>
        <span class="p">{</span>
            <span class="s">"CheckStockPrice"</span> <span class="p">=&gt;</span> <span class="k">new</span> <span class="nf">CheckStockPriceIntentProcessor</span><span class="p">(),</span>
            <span class="s">"GetPortfolio"</span> <span class="p">=&gt;</span> <span class="k">new</span> <span class="nf">GetPortfolioIntentProcessor</span><span class="p">(),</span>
            <span class="s">"BuyStocks"</span> <span class="p">=&gt;</span> <span class="k">new</span> <span class="nf">BuyStocksIntentProcessor</span><span class="p">(),</span>
            <span class="s">"SellStocks"</span> <span class="p">=&gt;</span> <span class="k">new</span> <span class="nf">SellStocksIntentProcessor</span><span class="p">(),</span>
            <span class="n">_</span> <span class="p">=&gt;</span> <span class="k">throw</span> <span class="k">new</span> <span class="nf">Exception</span><span class="p">(</span><span class="s">$"Intent with name </span><span class="p">{</span><span class="n">lexEvent</span><span class="p">.</span><span class="n">SessionState</span><span class="p">.</span><span class="n">Intent</span><span class="p">.</span><span class="n">Name</span><span class="p">}</span><span class="s"> is not supported"</span><span class="p">)</span>
        <span class="p">};</span>
        <span class="k">return</span> <span class="k">await</span> <span class="n">process</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="n">lexEvent</span><span class="p">,</span> <span class="n">context</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now you invoke the correct processor based on the intent name specified in the <code class="language-plaintext highlighter-rouge">LexV2Event</code> object.</p>

<p>Now deploy your function to AWS by running the following command in the terminal:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet lambda deploy-function
</code></pre></div></div>

<p>You have created your bot and backend separately. Now it’s the time to bring them together by pointing your bot to your Lambda function, which you will do in the next section.</p>

<h2 id="connect-your-chatbot-to-lambda">Connect your Chatbot to Lambda</h2>

<p>Go to the <a href="https://us-east-1.console.aws.amazon.com/lexv2/home?region=us-east-1#welcome">Lex Console</a>. Click Bots, then click StockBrokerBot. Click Aliases link, andon the Aliases page, click the Live alias.</p>

<p>In the languages section, click the English (US) link.</p>

<p>Now you should see a page that allows you to select a Lambda function and version of the function. In the Source list, select StockBrokerBot Lambda function and in the Lambda function version or alias list $LATEST should be automatically selected.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/26.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 26" /></p>

<p>Click the Save button.</p>

<p>Now when a request comes to Live alias, Lex will identify the intent and invoke your Lambda function. It will pass all the slot values and user info in a LexV2Event structure that your Lambda expects.</p>

<p>Almost everything is wired up. What’s left is to allow users to interact with your bot via SMS. Proceed to the next section to integrate with Twilio SMS.</p>

<h2 id="connect-your-chatbot-to-twilio">Connect your Chatbot to Twilio</h2>

<p>On the left menu, right under Aliases, there is a link to Channel integrations. Click that to list the existing integrations.</p>

<p>Click the Add channel button.</p>

<p>Amazon Lex supports 3 integration platforms: Facebook, Slack and Twilio SMS.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/27.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 27" /></p>

<p>Select Twilio SMS.</p>

<p>In the Integration configuration section, enter TwilioIntegration as the name, select Live in the Alias list and English (US) in the language list.</p>

<p>In the Additional configuration section, you will need your Twilio Account SID and Authentication token.</p>

<p>Open the <a href="https://www.twilio.com/console">Twilio Console</a>. On the main page, you should see the Account Info section.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/28.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 28" /></p>

<p>Copy your Account SID and Auth Token values and paste them in the corresponding inputs in the AWS console.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/29.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 29" /></p>

<p>Click the Create button.</p>

<p>The Twilio SMS integration should now appear in the list.</p>

<p>Click the channel name to view the details.</p>

<p>Scroll down to the Callback URL section.</p>

<p>You should see an auto-generated webhook URL that Lex expects Twilio to post data to.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/30.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 30" /></p>

<p>To complete the integration, copy the link and go to the <a href="https://www.twilio.com/console">Twilio console</a>. Select your account, and then click Phone Numbers → Manage → Active Numbers on the left pane. (If Phone Numbers isn’t on the left pane, click Explore Products and then on Phone Numbers.)</p>

<p>Click the phone number you want to use for your project and scroll down to the Messaging section.</p>

<p>In the “A MESSAGE COMES IN” section, select Webhook and paste the callback URL into the input field. Select HTTP POST in the next dropdown.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/31.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 31" /></p>

<p>Click the Save button at the bottom of the screen.</p>

<h2 id="test-your-chatbot-via-sms">Test your chatbot via SMS</h2>

<p>Finally, it’s time to test your chatbot.</p>

<p>From your phone, send an SMS to your Twilio Phone Number with the following message: check price. You should get a response asking for the stock name. Send the name of one of the stocks in your database such as Tesla. It also understands the stock name directly as it’s in your utterance list. So you can simplify it by sending the stock name directly and you should still get an answer.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-CSharp-Amazon-Lex-and-Twilio-SMS/32.png" alt="Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS - image 32" /></p>

<p>Now send the following message “buy shares” and your bot should reply by asking the stock name first and then the number of shares. You should see your updated portfolio after the stocks have been bought for you.</p>

<p>You can play around with different utterances and intents.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this tutorial, you learned how to implement your chatbot from scratch using C#. You also used the Twilio SMS integration with your Amazon Lex chatbot to allow users to interact with your bot via SMS.</p>

<p>A chatbot can be very useful to automate some processes saving time and money for your business. It’s also beneficial for the users as they can use your system outside of business hours.</p>

<p>If you’d like to keep learning, I recommend taking a look at these articles:</p>

<ul>
  <li>
    <p><a href="https://www.twilio.com/blog/create-an-sms-chatbot-using-amazon-lex-and-twilio-sms">Create an SMS chatbot using Amazon Lex and Twilio SMS</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/benefits-of-chatbots">13 Undeniable Benefits of Chatbots (Plus Challenges)</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/schedule-surprise-messages-with-twilio-sms">Schedule surprise messages with Twilio SMS for a mystical date</a></p>
  </li>
</ul>
]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[Create an SMS chatbot using Amazon Lex and Twilio SMS]]></title>
    <link href="https://volkanpaksoy.com/archive/2026/08/05/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/"/>
    <updated>2026-08-05T12:20:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2026/08/05/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS</id>
    <content type="html"><![CDATA[<blockquote>
  <p>This article was originally published on the <a href="https://www.twilio.com/en-us/blog/developers/community/create-an-sms-chatbot-using-amazon-lex-and-twilio-sms">Twilio Blog</a>.</p>
</blockquote>

<p>Customer service is an integral part of any business. Today, people expect speed and convenience from customer services when they need to get answers. Twilio already has built-in <a href="https://www.twilio.com/solutions/customer-service">solutions</a> to improve customer service. In addition to that, you can implement your chatbot with <a href="https://aws.amazon.com/lex/">Amazon Lex</a> and integrate it with Twilio SMS so that your customers can interact with your bot easily using SMS. In this article, you will learn how to achieve this.</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>You’ll need the following things in this tutorial:</p>

<ul>
  <li>
    <p>A free <a href="https://www.twilio.com/try-twilio">Twilio account</a></p>
  </li>
  <li>
    <p>A <a href="https://support.twilio.com/hc/en-us/articles/223135247-How-to-Search-for-and-Buy-a-Twilio-Phone-Number-from-Console">Twilio Phone Number</a> with SMS capability</p>
  </li>
  <li>
    <p>A free <a href="https://aws.amazon.com/free/">AWS account</a></p>
  </li>
</ul>

<h2 id="what-is-amazon-lex">What is Amazon Lex?</h2>

<p>Amazon Lex is an artificial intelligence service that allows developers to create voice or text-based conversational interfaces. This service powers Amazon’s own Alexa.</p>

<p>Lex provides automatic speech recognition and natural language understanding technologies. It takes the user’s input, runs it through a Natural Language Processing (NLP) engine and determines the user’s intent. The value of this is the user does not need to remember a set of commands to interact with your bot. They can talk to the bot just like they would to a human being.</p>

<h2 id="create-your-chatbot">Create your Chatbot</h2>

<p>Go to <a href="https://us-east-1.console.aws.amazon.com/lexv2/home?region=us-east-1#bots">Lex dashboard</a> and click the Create bot button.</p>

<p>In this article, you will use one of the demo bots that LexV2 comes with, as it is sufficient to illustrate how to connect a Lex chatbot to Twilio SMS.</p>

<p>In the Configure bot settings page, click Start with an example.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/01.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 1" /></p>

<p>In the Example bots section, select BookTrip.</p>

<p>In the Bot configuration section, enter BookTripBot as the name. You can enter a description if you like.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/02.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 2" /></p>

<p>In IAM permissions, select Create a role with basic Amazon Lex permissions. AWS will generate the role name for you, so you don’t have to set the name.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/03.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 3" /></p>

<p>In the Children’s Online Privacy Protection Act (COPPA) section, select No. This only applies if you collect information from children under 13 therefore, it doesn’t apply to your demo project.</p>

<p>Leave the remaining settings with their default values and click the Next button.</p>

<p>In the next section, you can choose to add multiple languages. The <a href="https://docs.aws.amazon.com/lexv2/latest/dg/how-languages.html">supported language and locale list can be found here</a>. You can even assign a different voice to each language. In this project, you will use SMS-based interaction, so select “None. This is only a text-based application” in the Voice interaction dropdown list.</p>

<p>Leave the default for the score threshold and click Done.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/04.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 4" /></p>

<p>After the bot creation, Lex takes you to the intent list. An intent is an action your bot takes to fulfil a user’s request. It’s different from traditional command-based interactions, where you need to know the exact command and the order of arguments. In this model, you interact with the bot just like you would talk to a human being. Your input is put through an NLP engine to determine your intent.</p>

<p>Scroll down to the Sample utterances section. You can see the same intent is expressed in multiple ways as humans do in a normal conversation.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/05.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 5" /></p>

<p><code class="language-plaintext highlighter-rouge">{Nights}</code> and <code class="language-plaintext highlighter-rouge">{Location}</code> shown in the third utterance are called slots. Scroll down to the Slots section to see how they are defined.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/06.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 6" /></p>

<p>A slot is another important concept. As you can see in the utterances, some of the slots are used as placeholders and can be extracted from the utterance to fill those values. A slot can be defined as required, which is the case here. Expand the Location panel to see the details:</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/07.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 7" /></p>

<p>This slot is marked as required, meaning that if the user does not supply this value, Lex will keep asking it until it fills the slot. The question it will ask is determined by the phrase you put in the Prompts field (“What city will you be staying in?” In this example).</p>

<p>Another thing to note is the data type. This slot has the AMAZON.City built-in slot type. It’s not a free-form string field. Amazon provides these <a href="https://docs.aws.amazon.com/lex/latest/dg/howitworks-builtins-slots.html">built-in slot types</a>. These are pre-defined models that are trained by Amazon.</p>

<p>You can also create your custom slot types as you can see in the RoomType slot, which has a custom RoomTypeValues slot type.</p>

<p>Click the Save Intent button</p>

<p>Scroll to the top and click Language: English (US) link in the breadcrumb. On the left pane, you should see two links under the language: Intents and Slot types.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/08.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 8" /></p>

<p>You looked into intents; now click the Slot types link to see how the custom types are defined. For this example bot, Amazon defined two custom slots:</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/09.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 9" /></p>

<p>Click RoomTypeValues.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/10.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 10" /></p>

<p>The default value resolution method is Expand values which is the one used in this example. This way, you are not providing a closed set of values but rather a sample dataset to train your machine learning model.</p>

<p>Lex performs better when you train your models with comprehensive values for your slots and utterances.</p>

<p>Click the Build button at the top of the screen.</p>

<p>After a show while, you should get a Successfully built notification.</p>

<p>Dismiss the notification and click the Bot: BookTripBot link in the breadcrumb. Take a look at the left pane again and note two important concepts: Versions and Aliases.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/11.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 11" /></p>

<p>Currently, you are on the Draft version. The draft version is the work-in-progress version of your bot. When you want your updates to take effect, you have to publish a new numbered version. These are simple integer auto-incremented numbers. They are read-only snapshots of the current state of your bot.</p>

<p>To publish your first version, click the Bot versions link on the left pane, then click the Create version button.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/12.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 12" /></p>

<p>It’s useful to give a meaningful description as the number of versions grows you tend to forget which feature was released with which version.</p>

<p>Put a description such as “The initial version of the example project with three intents and two slot types” and click the Create button at the bottom of the page.</p>

<p>After a few seconds, you should see your new version in the list.</p>

<p>Click Version 1 then Intents. You should a reminder from Amazon saying that this is a read-only version.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/13.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 13" /></p>

<p>As mentioned above, this is an immutable snapshot of your bot. If you make further changes, you will need to publish a new version. You cannot update existing versions.</p>

<p>Now click Aliases on the left menu. It should show the default TestBotAlias:</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/14.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 14" /></p>

<p>An alias is associated with a specific version of your bot. The benefit of this is you can have multiple aliases, such as test and live. If you publish a new version, you can point the test alias to the new version. This way, your live alias is not affected until you test your changes. After you’re satisfied your new version is ready to go live, you can simply associate the live version with the new version and all the new requests will come to the new version of your bot. Also, if you experience issues with your latest version, you can simply assign the previous version to your alias to roll back. This kind of separation between the versions and aliases makes change management a lot easier.</p>

<p>Click the Create alias button.</p>

<p>Enter Live as Alias name. In the Associate with a version section, choose Version 1. The language comes already enabled, so leave it like that.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/15.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 15" /></p>

<p>Click the Create button.</p>

<p>You should see the alias is successfully created and shown in the list.</p>

<p>Now that your bot has been published, move on to the next section to integrate it with Twilio.</p>

<h2 id="connect-your-chatbot-to-twilio">Connect your Chatbot to Twilio</h2>

<p>Your bot is live, but the users don’t have a way to interact with it.</p>

<p>To fix this, click the Channel integrations link on the left menu.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/16.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 16" /></p>

<p>Click the Add channel button.</p>

<p>Amazon Lex supports 3 integration platforms: Facebook, Slack, and Twilio SMS.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/17.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 17" /></p>

<p>Select Twilio SMS.</p>

<p>In the Integration configuration section, enter TwilioIntegration as the name, select Live in the Alias list and English (US) in the language list.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/18.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 18" /></p>

<p>In the Additional configuration section, you will need your Twilio Account SID and Authentication token.</p>

<p>Open the <a href="https://www.twilio.com/console">Twilio Console</a>. On the main page, you should see the Account Info section.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/19.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 19" /></p>

<p>Copy your Account SID and Auth Token values and paste them in the corresponding inputs in the AWS console.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/20.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 20" /></p>

<p>Click the Create button.</p>

<p>Twilio SMS integration should appear in the list. Click the channel name to view the details.</p>

<p>Scroll down to the Callback URL section.</p>

<p>You should see an auto-generated webhook URL that Lex expects Twilio to post data to.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/21.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 21" /></p>

<p>To complete the integration, copy the link and go to the <a href="https://www.twilio.com/console">Twilio console</a>. Select your account, and then click Phone Numbers → Manage → Active Numbers on the left pane. (If Phone Numbers isn’t on the left pane, click Explore Products and then on Phone Numbers.)</p>

<p>Click the phone number you want to use for your project and scroll down to the Messaging section.</p>

<p>In the “A MESSAGE COMES IN” section, select Webhook and paste the callback URL into the input field. Select HTTP POST in the next dropdown.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/22.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 22" /></p>

<p>Click the Save button at the bottom of the screen.</p>

<h2 id="test-your-chatbot-via-sms">Test your chatbot via SMS</h2>

<p>Finally, it’s time to test your chatbot.</p>

<p>From your phone, send an SMS to your Twilio phone number with the following message: Book a trip.</p>

<p>You should get a response from the bot asking the city.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/23.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 23" /></p>

<p>Remember this is the prompt message saw earlier and it’s shown now because the City slot is required. Send a city name to your liking and follow the bot’s prompts to complete the booking process.</p>

<p>Below is a screenshot of an example conversation:</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/24.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 24" /></p>

<p>To the city question. I responded: “I will stay in London”. My answer was not a single word, but Lex was able to get the city name from the context.</p>

<p>Another thing is I didn’t specify the exact date. I said “Next Monday” yet it was smart enough to understand the date I meant.</p>

<p>Lex asks these questions without coming to your backend so that when it does post this data to your backend Lambda function, you can rest assured that the heavy lifting was already done and all the variables are already filled in. By default, invoking a Lambda function is not enabled. You can configure this behavior by enabling the fulfillment and ticking the Use a Lambda function for fulfilment checkbox in the Advanced options. You can read more about <a href="https://docs.aws.amazon.com/lexv2/latest/dg/intent-fulfillment.html">fulfillment</a> and <a href="https://docs.aws.amazon.com/lexv2/latest/dg/lambda.html">using Lambda functions</a> on AWS documentation.</p>

<p><img src="/images/vpblogimg/2026/08/Create-an-SMS-chatbot-using-Amazon-Lex-and-Twilio-SMS/25.png" alt="Create an SMS chatbot using Amazon Lex and Twilio SMS - image 25" /></p>

<h2 id="conclusion">Conclusion</h2>

<p>In this tutorial, you learned how to create a chatbot using Amazon’s Lex service. You also created a Twilio SMS integration so that users can interact with your bot via SMS.</p>

<p>Amazon Lex leverages machine learning and captures the user’s intent and the values your program needs. SMS is probably the most ubiquitous client application in the world. Users can interact with your chatbot without having to install anything on their phones. Having these two technologies combined gives you great power to develop smart chatbots.</p>

<p>If you’d like to keep learning, I recommend taking a look at these articles:</p>

<ul>
  <li>
    <p><a href="https://www.twilio.com/blog/schedule-surprise-messages-with-twilio-sms">Schedule surprise messages with Twilio SMS for a mystical date</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/get-secrets-from-hashicorp-vault-into-dotnet-configuration-with-csharp">How to get secrets from HashiCorp Vault into .NET configuration with C#</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/configure-twilio-webhooks-with-visual-studio-dev-tunnels-during-aspdotnet-core-startup">Configure Twilio Webhooks automatically with Visual Studio dev tunnels during ASP.NET Core startup</a></p>
  </li>
</ul>
]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[Build a Voicemail Inbox using Twilio Voice and Blazor (Part 2)]]></title>
    <link href="https://volkanpaksoy.com/archive/2026/08/05/Build-a-Voicemail-Inbox-using-Twilio-Voice-and-Blazor-Part-2/"/>
    <updated>2026-08-05T12:15:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2026/08/05/Build-a-Voicemail-Inbox-using-Twilio-Voice-and-Blazor-Part-2</id>
    <content type="html"><![CDATA[<blockquote>
  <p>This article was originally published on the <a href="https://www.twilio.com/en-us/blog/developers/community/build-a-voicemail-inbox-using-twilio-voice-and-blazor">Twilio Blog</a>.</p>
</blockquote>

<p>In <a href="https://www.twilio.com/blog/build-a-voicemail-service-using-twilio-voice-and-aspdotnet-core">Part 1</a> of this series, you implemented a voicemail service that you can manage by calling your own Twilio number. You could then listen to the voice messages and choose to save or delete them. In this article, you will improve your voicemail service by adding a GUI so that you can carry out the same operations via the web and also see more information about the call, such as the caller number, duration and even the transcript of the message. If this sounds interesting to you, let’s get started!</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>You’ll need the following things in this tutorial:</p>

<ul>
  <li>
    <p>A free <a href="https://www.twilio.com/try-twilio">Twilio account</a></p>
  </li>
  <li>
    <p>A <a href="https://support.twilio.com/hc/en-us/articles/223135247-How-to-Search-for-and-Buy-a-Twilio-Phone-Number-from-Console">Twilio phone number</a></p>
  </li>
  <li>
    <p>An OS that supports .NET (Windows/macOS/Linux)</p>
  </li>
  <li>
    <p><a href="https://dotnet.microsoft.com/en-us/download/dotnet/7.0">.NET 7.0 SDK</a> (newer and older versions may work too)</p>
  </li>
  <li>
    <p>A code editor or IDE (Recommended: <a href="https://code.visualstudio.com/Download">Visual Studio Code</a> with <a href="https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp">the C# plugin</a>, <a href="https://visualstudio.microsoft.com/">Visual Studio</a>, or <a href="https://www.jetbrains.com/rider/">JetBrains Rider</a>)</p>
  </li>
  <li>
    <p><a href="https://ngrok.com/">ngrok</a> (A <a href="https://dashboard.ngrok.com/signup">free ngrok account</a> is sufficient for this tutorial)</p>
  </li>
  <li>
    <p>​​<a href="https://git-scm.com/downloads">Git CLI</a></p>
  </li>
  <li>
    <p>Experience with ASP.NET Core, <a href="https://www.twilio.com/docs/usage/webhooks/voice-webhooks">the Twilio voice webhook</a>, and <a href="https://www.twilio.com/docs/voice/twiml">TwiML</a></p>
  </li>
</ul>

<h2 id="project-overview">Project Overview</h2>

<p>The project will start where <a href="https://www.twilio.com/blog/build-a-voicemail-service-using-twilio-voice-and-aspdotnet-core">Part 1</a> ended. The older version relies on filenames to keep the state of the recordings (new vs saved). In this version, you will introduce a SQLite database to store the metadata such as caller number, call duration, etc.</p>

<h2 id="project-setup">Project setup</h2>

<p>The easiest way to set up the starter project is by cloning the sample <a href="https://github.com/Dev-Power/voicemail-service-with-gui-using-twilio-and-blazor">GitHub repository</a>.</p>

<p>Open a terminal, change to the directory you want to download the project (on the starter-project branch) and run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/Dev-Power/voicemail-service-with-gui-using-twilio-and-blazor <span class="nt">--branch</span> starter-project
</code></pre></div></div>

<p>Before making any changes, ensure the current version is in working order.</p>

<p>Run the following command to start tunneling:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ngrok http http://localhost:5096
</code></pre></div></div>

<p>Update the Twilio incoming call webhook URL with the Forwarding URL assigned to you by ngrok, and add the /IncomingCall path. Leave the ngrok tunnel running and open a separate shell for the upcoming commands.</p>

<p>In a new shell, change directories to the web API project.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd </span>voicemail-service-with-gui-using-twilio-and-blazor/src/VoicemailDirectory.WebApi
</code></pre></div></div>

<p>To test leaving a voicemail, remove your phone number from the <code class="language-plaintext highlighter-rouge">Voicemail:Owners</code> array in</p>

<p>appsettings.json and run the application with the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet run
</code></pre></div></div>

<p>Call your Twilio phone number, and you should be able to record a message. The saved message should appear under the wwwroot/Voicemails directory.</p>

<p>Update the appsettings.json by adding your number to the owner’s list (using <a href="https://support.twilio.com/hc/en-us/articles/223183008-Formatting-International-Phone-Numbers#:~:text=Twilio%20recommends%20using%20E.,SMS%20messages%20across%20the%20globe.">E. 164 formatting</a>) and call your Twilio phone number again.</p>

<p>This time you should be greeted with a message telling you have one new message and no saved messages, followed by the recorded message.</p>

<p>If your project works so far, you’re ready to move on to the next section to refactor and improve. If not, please refer to the <a href="https://www.twilio.com/blog/build-a-voicemail-service-using-twilio-voice-and-aspdotnet-core">original article</a> and make sure your setup works.</p>

<h2 id="use-ef-core-and-a-sqlite-database-to-store-metadata">Use EF Core and a SQLite database to store metadata</h2>

<p>In the first version, you used the filename to store very limited metadata about the recording: Call status, which can be “New” or “Saved”. This was used to order the recordings to play the new ones first.</p>

<p>In this version, you will use a local SQLite database instead. First, start by adding <a href="https://www.nuget.org/packages/Microsoft.EntityFrameworkCore.Sqlite">the EF Core SQLite NuGet package</a> to your API project:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package Microsoft.EntityFrameworkCore.Sqlite
</code></pre></div></div>

<p>Create a new directory called Data and add a new C# file under it called RecordingContext.cs. Update the contents as below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.EntityFrameworkCore</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">VoicemailDirectory.WebApi.Data</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">RecordingContext</span> <span class="p">:</span> <span class="n">DbContext</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="n">DbSet</span><span class="p">&lt;</span><span class="n">Recording</span><span class="p">&gt;?</span> <span class="n">Recordings</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="nf">RecordingContext</span><span class="p">(</span><span class="n">DbContextOptions</span><span class="p">&lt;</span><span class="n">RecordingContext</span><span class="p">&gt;</span> <span class="n">options</span><span class="p">)</span> <span class="p">:</span> <span class="k">base</span><span class="p">(</span><span class="n">options</span><span class="p">)</span>
    <span class="p">{</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The context class you created above refers to an entity class called <code class="language-plaintext highlighter-rouge">Recording</code> which you will use to save the recording metadata. Next, create a new file under the Data directory called Recording.cs and update it as shown below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">VoicemailDirectory.WebApi.Data</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">Recording</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="kt">int</span> <span class="n">Id</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="n">RecordingSID</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="n">DateTime</span> <span class="n">Date</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="n">TimeSpan</span> <span class="n">Duration</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="n">CallerNumber</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="n">Transcription</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="n">RecordingStatus</span> <span class="n">Status</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
<span class="p">}</span>
<span class="k">public</span> <span class="k">enum</span> <span class="n">RecordingStatus</span>
<span class="p">{</span>
    <span class="n">New</span><span class="p">,</span>
    <span class="n">Saved</span>
<span class="p">}</span>
</code></pre></div></div>

<p><a href="https://sqlite.org/index.html">SQLite</a> is a file-based SQL database engine. EF Core is going to create the database for you, as you will see later, but first, you have to specify the path for the database file.</p>

<p>Open Program.cs file and add the highlighted lines as shown below:</p>

<p>```csharp hl_lines=”3 4 5 6”
builder.Services.AddControllers();
var folder = Environment.SpecialFolder.LocalApplicationData;
var path = Environment.GetFolderPath(folder);
var dbPath = Path.Join(path, “recordings.db”);
builder.Services.AddDbContext<RecordingContext>(options =&gt; options.UseSqlite($"Data Source={dbPath}"));
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();</RecordingContext></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
Also, add the using statements at the top:

```csharp
using Microsoft.EntityFrameworkCore;
using VoicemailDirectory.WebApi.Data;
</code></pre></div></div>

<p>You can use any path or file name you like. The above example uses the local data path and uses “recordings.db” as the filename.</p>

<p>You can now inject the <code class="language-plaintext highlighter-rouge">RecordingContext</code> class wherever you want to carry out database operations. Alternatively, you can create a separate class to encapsulate all the database operations. This way your business logic doesn’t become tied to the implementation. To achieve this, create an interface under the Data directory called IRecordingRepository.cs with the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">VoicemailDirectory.WebApi.Data</span><span class="p">;</span>
<span class="k">public</span> <span class="k">interface</span> <span class="nc">IRecordingRepository</span>
<span class="p">{</span>
    <span class="n">Task</span> <span class="nf">NewRecording</span><span class="p">(</span><span class="n">Recording</span> <span class="n">recording</span><span class="p">);</span>
    <span class="n">Task</span> <span class="nf">UpdateCallerAndTranscription</span><span class="p">(</span><span class="kt">string</span> <span class="n">recordingSID</span><span class="p">,</span> <span class="kt">string</span> <span class="n">caller</span><span class="p">,</span> <span class="kt">string</span> <span class="n">transcriptionText</span><span class="p">);</span>
    <span class="n">Task</span> <span class="nf">ChangeStatusToSaved</span><span class="p">(</span><span class="kt">string</span> <span class="n">recordingSID</span><span class="p">);</span>
    <span class="n">Task</span> <span class="nf">Delete</span><span class="p">(</span><span class="kt">string</span> <span class="n">recordingSID</span><span class="p">);</span>
    <span class="n">List</span><span class="p">&lt;</span><span class="n">Recording</span><span class="p">&gt;</span> <span class="nf">GetAll</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This interface defines all the database operations you will need:</p>

<ul>
  <li>
    <p><code class="language-plaintext highlighter-rouge">NewRecording</code>: Creates and inserts a new Recording object when a new message comes in.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">UpdateCallerAndTranscription</code>: It takes a while to get the transcription, and it’s handled in a different endpoint. When Twilio calls your endpoint with the transcript info, this method is called to update the record. It also updates the caller number as it’s not present in the recording status callback.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">ChangeStatusToSaved</code>: Updates the status column to Saved for the specified recording.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">Delete</code>: Removes the recording metadata from the database.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">GetAll</code>: Returns all recordings in the database.</p>
  </li>
</ul>

<p>To implement this interface, create a new class called <code class="language-plaintext highlighter-rouge">RecordingRepository</code> under the Data directory:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">VoicemailDirectory.WebApi.Data</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">RecordingRepository</span> <span class="p">:</span> <span class="n">IRecordingRepository</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">RecordingContext</span> <span class="n">_recordingContext</span><span class="p">;</span>
    <span class="k">public</span> <span class="nf">RecordingRepository</span><span class="p">(</span><span class="n">RecordingContext</span> <span class="n">recordingContext</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_recordingContext</span> <span class="p">=</span> <span class="n">recordingContext</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">NewRecording</span><span class="p">(</span><span class="n">Recording</span> <span class="n">recording</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">await</span> <span class="n">_recordingContext</span><span class="p">.</span><span class="n">Recordings</span><span class="p">.</span><span class="nf">AddAsync</span><span class="p">(</span><span class="n">recording</span><span class="p">);</span>
        <span class="k">await</span> <span class="n">_recordingContext</span><span class="p">.</span><span class="nf">SaveChangesAsync</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">UpdateCallerAndTranscription</span><span class="p">(</span><span class="kt">string</span> <span class="n">recordingSID</span><span class="p">,</span> <span class="kt">string</span> <span class="n">caller</span><span class="p">,</span> <span class="kt">string</span> <span class="n">transcriptionText</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">recording</span> <span class="p">=</span> <span class="n">_recordingContext</span><span class="p">.</span><span class="n">Recordings</span><span class="p">.</span><span class="nf">Single</span><span class="p">(</span><span class="n">rec</span> <span class="p">=&gt;</span> <span class="n">rec</span><span class="p">.</span><span class="n">RecordingSID</span> <span class="p">==</span> <span class="n">recordingSID</span><span class="p">);</span>
        <span class="n">recording</span><span class="p">.</span><span class="n">CallerNumber</span> <span class="p">=</span> <span class="n">caller</span><span class="p">;</span>
        <span class="n">recording</span><span class="p">.</span><span class="n">Transcription</span> <span class="p">=</span> <span class="n">transcriptionText</span><span class="p">;</span>
        <span class="k">await</span> <span class="n">_recordingContext</span><span class="p">.</span><span class="nf">SaveChangesAsync</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">ChangeStatusToSaved</span><span class="p">(</span><span class="kt">string</span> <span class="n">recordingSID</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">recording</span> <span class="p">=</span> <span class="n">_recordingContext</span><span class="p">.</span><span class="n">Recordings</span><span class="p">.</span><span class="nf">Single</span><span class="p">(</span><span class="n">rec</span> <span class="p">=&gt;</span> <span class="n">rec</span><span class="p">.</span><span class="n">RecordingSID</span> <span class="p">==</span> <span class="n">recordingSID</span><span class="p">);</span>
        <span class="n">recording</span><span class="p">.</span><span class="n">Status</span> <span class="p">=</span> <span class="n">RecordingStatus</span><span class="p">.</span><span class="n">Saved</span><span class="p">;</span>
        <span class="k">await</span> <span class="n">_recordingContext</span><span class="p">.</span><span class="nf">SaveChangesAsync</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">Delete</span><span class="p">(</span><span class="kt">string</span> <span class="n">recordingSID</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">recording</span> <span class="p">=</span> <span class="n">_recordingContext</span><span class="p">.</span><span class="n">Recordings</span><span class="p">.</span><span class="nf">Single</span><span class="p">(</span><span class="n">rec</span> <span class="p">=&gt;</span> <span class="n">rec</span><span class="p">.</span><span class="n">RecordingSID</span> <span class="p">==</span> <span class="n">recordingSID</span><span class="p">);</span>
        <span class="n">_recordingContext</span><span class="p">.</span><span class="n">Recordings</span><span class="p">.</span><span class="nf">Remove</span><span class="p">(</span><span class="n">recording</span><span class="p">);</span>
        <span class="k">await</span> <span class="n">_recordingContext</span><span class="p">.</span><span class="nf">SaveChangesAsync</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="n">List</span><span class="p">&lt;</span><span class="n">Recording</span><span class="p">&gt;</span> <span class="nf">GetAll</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="n">_recordingContext</span><span class="p">.</span><span class="n">Recordings</span><span class="p">.</span><span class="nf">ToList</span><span class="p">();</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The repository class encapsulates <code class="language-plaintext highlighter-rouge">RecordingContext</code> and carries out the DB operations via EF Core.</p>

<p>You also have to register it to the IoC container by updating Program.cs as shown below:</p>

<p>```csharp hl_lines=”2”
builder.Services.AddTransient<FileService>();
builder.Services.AddTransient&lt;IRecordingRepository, RecordingRepository&gt;();
builder.Services.Configure<VoicemailOptions>(builder.Configuration.GetSection("Voicemail"));</VoicemailOptions></FileService></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
Finally, it's time to create the database. Run the following commands to scaffold the migrations and create the database:

```bash
dotnet tool install --global dotnet-ef
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet ef migrations add InitialCreate
dotnet ef database update
</code></pre></div></div>

<p>You should now see a directory inside your project called Migrations and the recordings.db file in your local data path.</p>

<p><img src="/images/vpblogimg/2026/08/Build-a-Voicemail-Inbox-using-Twilio-Voice-and-Blazor-Part-2/01.png" alt="Build a Voicemail Inbox using Twilio Voice and Blazor (Part 2) - image 1" /></p>

<h2 id="refactor-controllers-and-services">Refactor Controllers and Services</h2>

<p>Now that you have a database layer, it’s time to refactor the controllers to make use of it.</p>

<p>Open RecordController.cs under the Controllers directory and set up <code class="language-plaintext highlighter-rouge">IRecordingRepository</code> as shown below:</p>

<p>```csharp hl_lines=”3 8 13”
    private readonly ILogger<RecordController> _logger;
    private readonly FileService _fileService;
    private readonly IRecordingRepository _recordingRepository;
    public RecordController(
        ILogger<RecordController> logger,
        FileService fileService,
        IRecordingRepository recordingRepository
    )
    {
        _logger = logger;
        _fileService = fileService;
        _recordingRepository = recordingRepository;
    }</RecordController></RecordController></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
Also add this required using statement:

```csharp
using VoicemailDirectory.WebApi.Data;
</code></pre></div></div>

<p>Replace the <code class="language-plaintext highlighter-rouge">RecordingStatus</code> action with the code below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code>   <span class="p">[</span><span class="n">HttpPost</span><span class="p">]</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">RecordingStatus</span><span class="p">(</span>
        <span class="p">[</span><span class="n">FromForm</span><span class="p">]</span> <span class="kt">string</span> <span class="n">callSid</span><span class="p">,</span>
        <span class="p">[</span><span class="n">FromForm</span><span class="p">]</span> <span class="kt">string</span> <span class="n">recordingUrl</span><span class="p">,</span>
        <span class="p">[</span><span class="n">FromForm</span><span class="p">]</span> <span class="kt">string</span> <span class="n">recordingSid</span><span class="p">,</span>
        <span class="p">[</span><span class="n">FromForm</span><span class="p">]</span> <span class="kt">string</span> <span class="n">recordingStatus</span><span class="p">,</span>
        <span class="p">[</span><span class="n">FromForm</span><span class="p">]</span> <span class="kt">string</span> <span class="n">recordingStartTime</span><span class="p">,</span>
        <span class="p">[</span><span class="n">FromForm</span><span class="p">]</span> <span class="kt">string</span> <span class="n">recordingDuration</span>
    <span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_logger</span><span class="p">.</span><span class="nf">LogInformation</span><span class="p">(</span>
            <span class="s">"Recording status changed to {recordingStatus} for call {callSid}. Recording is available at {recordingUrl}"</span><span class="p">,</span>
            <span class="n">recordingStatus</span><span class="p">,</span> <span class="n">callSid</span><span class="p">,</span> <span class="n">recordingUrl</span>
        <span class="p">);</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">recordingStatus</span> <span class="p">==</span> <span class="s">"completed"</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">await</span> <span class="n">_recordingRepository</span><span class="p">.</span><span class="nf">NewRecording</span><span class="p">(</span><span class="k">new</span> <span class="n">Recording</span>
            <span class="p">{</span>
                <span class="n">RecordingSID</span> <span class="p">=</span> <span class="n">recordingSid</span><span class="p">,</span>
                <span class="n">Status</span> <span class="p">=</span> <span class="n">Data</span><span class="p">.</span><span class="n">RecordingStatus</span><span class="p">.</span><span class="n">New</span><span class="p">,</span>
                <span class="n">Date</span> <span class="p">=</span> <span class="n">DateTime</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="n">recordingStartTime</span><span class="p">),</span>
                <span class="n">Duration</span> <span class="p">=</span> <span class="n">TimeSpan</span><span class="p">.</span><span class="nf">FromSeconds</span><span class="p">(</span><span class="kt">double</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="n">recordingDuration</span><span class="p">)),</span>
                <span class="n">CallerNumber</span> <span class="p">=</span> <span class="kt">string</span><span class="p">.</span><span class="n">Empty</span><span class="p">,</span>
                <span class="n">Transcription</span> <span class="p">=</span> <span class="kt">string</span><span class="p">.</span><span class="n">Empty</span>
            <span class="p">});</span>
            <span class="k">await</span> <span class="n">_fileService</span><span class="p">.</span><span class="nf">DownloadRecording</span><span class="p">(</span><span class="n">recordingUrl</span><span class="p">,</span> <span class="n">recordingSid</span><span class="p">);</span>
        <span class="p">}</span>
    <span class="p">}</span>
</code></pre></div></div>

<p>The previous version only downloaded the file. Now, in addition to that, you also insert the metadata into the database.</p>

<p>Another change in the controller needs to be done in the <code class="language-plaintext highlighter-rouge">Index</code> method. You will instruct Twilio to transcribe the call and send you the transcription by updating the <code class="language-plaintext highlighter-rouge">Record</code> call as below:</p>

<p>```csharp hl_lines=”7 8”
response.Record(
    timeout: 10,
    action: new Uri(Url.Action(“Bye”)!, UriKind.Relative),
    method: Twilio.Http.HttpMethod.Post,
    recordingStatusCallback: new Uri(Url.Action(“RecordingStatus”)!, UriKind.Relative),
    recordingStatusCallbackMethod: Twilio.Http.HttpMethod.Post,
    transcribe: true,
    transcribeCallback: new Uri(Url.Action(“TranscribeCallback”)!, UriKind.Relative)
);</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
And add the method to handle the transcription callback:

```csharp
[HttpPost]
public async Task TranscribeCallback(
    [FromForm] string recordingSid,
    [FromForm] string transcriptionText,
    [FromForm] string from
)
{
    _logger.LogInformation(
        "Updating the recording {recordingSid} from {from} with transcription: {transcriptionText}",
        recordingSid, from, transcriptionText
    );
    await _recordingRepository.UpdateCallerAndTranscription(recordingSid, from, transcriptionText);
}
</code></pre></div></div>

<p>Update your settings and remove your phone from the owner list to test recording a new message again. Repeat the previous test. This time, you should also see a new record in the recordings.db database.</p>

<p>!!!info</p>

<p>If you don’t have an application to manage SQLite databases already installed, you can download <a href="https://sqlitebrowser.org/dl/">DB Browser for SQLite</a> for free.</p>

<p>!!!</p>

<p>After you’ve saved your message, you should see a new call to your API:</p>

<p><img src="/images/vpblogimg/2026/08/Build-a-Voicemail-Inbox-using-Twilio-Voice-and-Blazor-Part-2/02.png" alt="Build a Voicemail Inbox using Twilio Voice and Blazor (Part 2) - image 2" /></p>

<p>The record is first inserted in the /Record/RecordingStatus endpoint. Then updated with the caller number and the call transcription in the /Record/TranscribeCallback endpoint.</p>

<p>Your recording row should look something like this in your database:</p>

<p><img src="/images/vpblogimg/2026/08/Build-a-Voicemail-Inbox-using-Twilio-Voice-and-Blazor-Part-2/03.png" alt="Build a Voicemail Inbox using Twilio Voice and Blazor (Part 2) - image 3" /></p>

<p>Next step is refactoring the <code class="language-plaintext highlighter-rouge">DirectoryController</code> class to use the database. Like <code class="language-plaintext highlighter-rouge">RecordController</code>, you need to inject the repository to handle the database operations.</p>

<p>Open DirectoryController.cs and set up <code class="language-plaintext highlighter-rouge">IRecordingRepository</code> as shown below:</p>

<p>```csharp hl_lines=”3 8 13”
    private readonly ILogger<DirectoryController> _logger;
    private readonly FileService _fileService;
    private readonly IRecordingRepository _recordingRepository;
    public DirectoryController(
        ILogger<DirectoryController> logger,
        FileService fileService,
        IRecordingRepository recordingRepository
    )
    {
        _logger = logger;
        _fileService = fileService;
        _recordingRepository = recordingRepository;
    }</DirectoryController></DirectoryController></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
Then, add the required using statement:

```csharp
using VoicemailDirectory.WebApi.Data;
</code></pre></div></div>

<p>Current version gets the recordings from the <code class="language-plaintext highlighter-rouge">FileService</code>. In this version, you will get that data from the database.</p>

<p>Update the first 3 lines of the <code class="language-plaintext highlighter-rouge">Index</code> method as shown below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">allRecordings</span> <span class="p">=</span> <span class="n">_recordingRepository</span><span class="p">.</span><span class="nf">GetAll</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">newMessages</span> <span class="p">=</span> <span class="n">allRecordings</span><span class="p">.</span><span class="nf">Where</span><span class="p">(</span><span class="n">rec</span> <span class="p">=&gt;</span> <span class="n">rec</span><span class="p">.</span><span class="n">Status</span> <span class="p">==</span> <span class="n">RecordingStatus</span><span class="p">.</span><span class="n">New</span><span class="p">).</span><span class="nf">ToList</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">savedMessages</span> <span class="p">=</span> <span class="n">allRecordings</span><span class="p">.</span><span class="nf">Where</span><span class="p">(</span><span class="n">rec</span> <span class="p">=&gt;</span> <span class="n">rec</span><span class="p">.</span><span class="n">Status</span> <span class="p">==</span> <span class="n">RecordingStatus</span><span class="p">.</span><span class="n">Saved</span><span class="p">).</span><span class="nf">ToList</span><span class="p">();</span>
</code></pre></div></div>

<p>Another change in this method is at the bottom, where you filter the recordings. Again, you will now use the <code class="language-plaintext highlighter-rouge">RecordingRepository</code> instead of the <code class="language-plaintext highlighter-rouge">FileService</code>. Replace the <code class="language-plaintext highlighter-rouge">var allMessages = _fileService.GetRecordingSids;</code> call with the code below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">allMessages</span> <span class="p">=</span> <span class="n">allRecordings</span>
    <span class="p">.</span><span class="nf">OrderBy</span><span class="p">(</span><span class="n">rec</span> <span class="p">=&gt;</span> <span class="n">rec</span><span class="p">.</span><span class="n">Status</span><span class="p">)</span> <span class="c1">// So the new ones come on top</span>
    <span class="p">.</span><span class="nf">ThenByDescending</span><span class="p">(</span><span class="n">rec</span> <span class="p">=&gt;</span> <span class="n">rec</span><span class="p">.</span><span class="n">Date</span><span class="p">)</span> <span class="c1">// Descending so that the newest ones come on top</span>
    <span class="p">.</span><span class="nf">Select</span><span class="p">(</span><span class="n">rec</span> <span class="p">=&gt;</span> <span class="n">rec</span><span class="p">.</span><span class="n">RecordingSID</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">ToList</span><span class="p">();</span>
</code></pre></div></div>

<p>Final modification in this controller will be in the <code class="language-plaintext highlighter-rouge">Gather</code> method. Update the actions as shown below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">case</span> <span class="m">2</span><span class="p">:</span> <span class="c1">// Save</span>
    <span class="k">await</span> <span class="n">_recordingRepository</span><span class="p">.</span><span class="nf">ChangeStatusToSaved</span><span class="p">(</span><span class="n">currentMessage</span><span class="p">);</span>
    <span class="n">queuedMessages</span><span class="p">.</span><span class="nf">Remove</span><span class="p">(</span><span class="n">currentMessage</span><span class="p">);</span>
    <span class="k">break</span><span class="p">;</span>
<span class="k">case</span> <span class="m">3</span><span class="p">:</span> <span class="c1">// Delete</span>
    <span class="n">_fileService</span><span class="p">.</span><span class="nf">DeleteRecording</span><span class="p">(</span><span class="n">currentMessage</span><span class="p">);</span>
    <span class="k">await</span> <span class="n">_recordingRepository</span><span class="p">.</span><span class="nf">Delete</span><span class="p">(</span><span class="n">currentMessage</span><span class="p">);</span>
    <span class="n">queuedMessages</span><span class="p">.</span><span class="nf">Remove</span><span class="p">(</span><span class="n">currentMessage</span><span class="p">);</span>
    <span class="k">break</span><span class="p">;</span>
</code></pre></div></div>

<p>This way, in addition to managing the actual MP3 files, you keep the database accurate.</p>

<p>For this update to work, you have to convert the <code class="language-plaintext highlighter-rouge">Gather</code> method to <code class="language-plaintext highlighter-rouge">async</code> by making the following change in the method signature:</p>

<p>```csharp hl_lines=”1”
   public async Task<TwiMLResult> Gather(
        [FromQuery] List<string> queuedMessages,
        [FromForm] int digits
    )</string></TwiMLResult></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
Due to all these changes, the functionality of the `FileService` has also changed. You don't have to prefix the file name with its status anymore. Therefore, you don't need a save method. Update the current service with the simplified version as shown below:

```csharp
namespace VoicemailDirectory.WebApi.Services;
public class FileService
{
    private readonly HttpClient _httpClient;
    private readonly string _rootVoicemailPath;
    public FileService(IHttpClientFactory httpClientFactory, IWebHostEnvironment webHostEnvironment)
    {
        _rootVoicemailPath = $"{webHostEnvironment.WebRootPath}/Voicemails";
        _httpClient = httpClientFactory.CreateClient();
    }
    public async Task DownloadRecording(string recordingUrl, string recordingSid)
    {
        using HttpResponseMessage response = await _httpClient.GetAsync($"{recordingUrl}.mp3");
        response.EnsureSuccessStatusCode();
        await using var fs = new FileStream(
            $"{_rootVoicemailPath}/{recordingSid}.mp3",
            FileMode.CreateNew
        );
        await response.Content.CopyToAsync(fs);
    }
    public void DeleteRecording(string recordingSid) =&gt; File.Delete(GetRecordingPathBySid(recordingSid));
    private string GetRecordingPathBySid(string recordingSid)
        =&gt; Directory.GetFiles($"{_rootVoicemailPath}/", "*.mp3")
            .Single(s =&gt; s.Contains(recordingSid));
}
</code></pre></div></div>

<p>Leave another message for yourself and test the existing functionality by adding your number to the owner list and calling back. Once you’ve confirmed everything is still working as expected, move on to the next section to implement a new controller for your future front-end.</p>

<h2 id="implement-the-new-controller">Implement the new controller</h2>

<p>Currently, you have a voice-based user interface where you’re prompted with options when you call your own number. The GUI you will implement will need to have a new API endpoint that it can talk to. To achieve this, create a new controller under the Controllers directory called <code class="language-plaintext highlighter-rouge">RecordingManagementController</code> and update its code as shown below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Mvc</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">VoicemailDirectory.WebApi.Data</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">VoicemailDirectory.WebApi.Services</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">VoicemailDirectory.WebApi.Controllers</span><span class="p">;</span>
<span class="p">[</span><span class="n">ApiController</span><span class="p">]</span>
<span class="p">[</span><span class="nf">Route</span><span class="p">(</span><span class="s">"[controller]/[action]"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">RecordingManagementController</span> <span class="p">:</span> <span class="n">ControllerBase</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">FileService</span> <span class="n">_fileService</span><span class="p">;</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">IRecordingRepository</span> <span class="n">_recordingRepository</span><span class="p">;</span>
    <span class="k">public</span> <span class="nf">RecordingManagementController</span><span class="p">(</span><span class="n">IRecordingRepository</span> <span class="n">recordingRepository</span><span class="p">,</span> <span class="n">FileService</span> <span class="n">fileService</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_recordingRepository</span> <span class="p">=</span> <span class="n">recordingRepository</span><span class="p">;</span>
        <span class="n">_fileService</span> <span class="p">=</span> <span class="n">fileService</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="p">[</span><span class="n">HttpGet</span><span class="p">]</span>
    <span class="k">public</span> <span class="n">ActionResult</span> <span class="nf">Index</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">recordings</span> <span class="p">=</span> <span class="n">_recordingRepository</span><span class="p">.</span><span class="nf">GetAll</span><span class="p">();</span>
        <span class="k">return</span> <span class="nf">Ok</span><span class="p">(</span><span class="n">recordings</span><span class="p">.</span><span class="nf">OrderByDescending</span><span class="p">(</span><span class="n">m</span> <span class="p">=&gt;</span> <span class="n">m</span><span class="p">.</span><span class="n">Date</span><span class="p">));</span>
    <span class="p">}</span>
    <span class="p">[</span><span class="nf">HttpPatch</span><span class="p">(</span><span class="s">"{recordingSid}"</span><span class="p">)]</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">ActionResult</span><span class="p">&gt;</span> <span class="nf">Save</span><span class="p">(</span><span class="kt">string</span> <span class="n">recordingSid</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">await</span> <span class="n">_recordingRepository</span><span class="p">.</span><span class="nf">ChangeStatusToSaved</span><span class="p">(</span><span class="n">recordingSid</span><span class="p">);</span>
        <span class="k">return</span> <span class="nf">NoContent</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="p">[</span><span class="nf">HttpDelete</span><span class="p">(</span><span class="s">"{recordingSid}"</span><span class="p">)]</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">ActionResult</span><span class="p">&gt;</span> <span class="nf">Delete</span><span class="p">(</span><span class="kt">string</span> <span class="n">recordingSid</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">await</span> <span class="n">_recordingRepository</span><span class="p">.</span><span class="nf">Delete</span><span class="p">(</span><span class="n">recordingSid</span><span class="p">);</span>
        <span class="n">_fileService</span><span class="p">.</span><span class="nf">DeleteRecording</span><span class="p">(</span><span class="n">recordingSid</span><span class="p">);</span>
        <span class="k">return</span> <span class="nf">NoContent</span><span class="p">();</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>As you can see, the goal here is to have the same functionality via the GUI.</p>

<p>Now that you have your API ready for the front-end, proceed to implement it.</p>

<h2 id="implement-the-front-end">Implement the front-end</h2>

<p>In the terminal, navigate to the solution level and run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet new blazorwasm <span class="nt">-o</span> VoicemailDirectory.Console
dotnet sln add ./VoicemailDirectory.Console/VoicemailDirectory.Console.csproj
</code></pre></div></div>

<p>This should create a new Blazor WebAssembly project and add it to your solution.</p>

<p>The Blazor application sets up and HTTP client to resolve HTTP requests to its own app URL, but you’ll need to make HTTP requests to the API project running on a separate URL. The web API runs on http://localhost:5096, so update your VoicemailDirectory.Console/Program.cs as shown below to reflect this:</p>

<p>```csharp hl_lines=”3”
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp =&gt; new HttpClient { BaseAddress = new Uri("http://localhost:5096") });
await builder.Build().RunAsync();</HeadOutlet></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
To get rid of the default navigation and sample pages, update the MainLayout.razor as shown below:

```csharp
@inherits LayoutComponentBase
&lt;div class="page"&gt;
    &lt;main&gt;
        &lt;article class="content px-4"&gt;
            @Body
        &lt;/article&gt;
    &lt;/main&gt;
&lt;/div&gt;
</code></pre></div></div>

<p>Also remove the following files to keep things clean:</p>

<ul>
  <li>
    <p>Shared/SurveyPrompt.razor</p>
  </li>
  <li>
    <p>Shared/NavMenu.razor</p>
  </li>
  <li>
    <p>Shared/NavMenu.razor.cs</p>
  </li>
  <li>
    <p>Pages/Counter.razor</p>
  </li>
  <li>
    <p>Pages/FetchData.razor</p>
  </li>
</ul>

<p>Replace the contents of Index.razor with the code below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">@page</span> <span class="s">"/"</span>
<span class="n">@inject</span> <span class="n">HttpClient</span> <span class="n">Http</span>
<span class="p">&lt;</span><span class="n">PageTitle</span><span class="p">&gt;</span><span class="n">Voicemail</span> <span class="n">Management</span> <span class="n">Console</span><span class="p">&lt;/</span><span class="n">PageTitle</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="n">h1</span><span class="p">&gt;</span><span class="n">Voicemail</span> <span class="n">Management</span> <span class="n">Console</span><span class="p">&lt;/</span><span class="n">h1</span><span class="p">&gt;</span>
<span class="nf">@if</span> <span class="p">(</span><span class="n">recordingMetadata</span> <span class="p">==</span> <span class="k">null</span><span class="p">)</span>
<span class="p">{</span>
    <span class="p">&lt;</span><span class="n">p</span><span class="p">&gt;</span><span class="n">No</span> <span class="n">recording</span> <span class="n">found</span><span class="p">.&lt;/</span><span class="n">p</span><span class="p">&gt;</span>
<span class="p">}</span>
<span class="k">else</span>
<span class="p">{</span>
    <span class="p">&lt;</span><span class="n">table</span> <span class="k">class</span><span class="err">="</span><span class="nc">table</span><span class="s">"&gt;
</span>        <span class="p">&lt;</span><span class="n">thead</span><span class="p">&gt;</span>
            <span class="p">&lt;</span><span class="n">tr</span><span class="p">&gt;</span>
                <span class="p">&lt;</span><span class="n">th</span><span class="p">&gt;</span><span class="n">Date</span><span class="p">&lt;/</span><span class="n">th</span><span class="p">&gt;</span>
                <span class="p">&lt;</span><span class="n">th</span><span class="p">&gt;</span><span class="n">Caller</span><span class="p">&lt;/</span><span class="n">th</span><span class="p">&gt;</span>
                <span class="p">&lt;</span><span class="n">th</span><span class="p">&gt;</span><span class="n">Duration</span><span class="p">&lt;/</span><span class="n">th</span><span class="p">&gt;</span>
                <span class="p">&lt;</span><span class="n">th</span><span class="p">&gt;</span><span class="n">Transcription</span><span class="p">&lt;/</span><span class="n">th</span><span class="p">&gt;</span>
                <span class="p">&lt;</span><span class="n">th</span><span class="p">&gt;</span><span class="n">Status</span><span class="p">&lt;/</span><span class="n">th</span><span class="p">&gt;</span>
                <span class="p">&lt;</span><span class="n">th</span><span class="p">&gt;</span><span class="n">Recording</span><span class="p">&lt;/</span><span class="n">th</span><span class="p">&gt;</span>
                <span class="p">&lt;</span><span class="n">th</span><span class="p">&gt;</span><span class="n">Actions</span><span class="p">&lt;/</span><span class="n">th</span><span class="p">&gt;</span>
            <span class="p">&lt;/</span><span class="n">tr</span><span class="p">&gt;</span>
        <span class="p">&lt;/</span><span class="n">thead</span><span class="p">&gt;</span>
        <span class="p">&lt;</span><span class="n">tbody</span><span class="p">&gt;</span>
            <span class="nf">@foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">recording</span> <span class="k">in</span> <span class="n">recordingMetadata</span><span class="p">)</span>
            <span class="p">{</span>
                <span class="p">&lt;</span><span class="n">tr</span><span class="p">&gt;</span>
                    <span class="p">&lt;</span><span class="n">td</span><span class="p">&gt;</span><span class="n">@recording</span><span class="p">.</span><span class="n">Date</span><span class="p">.</span><span class="nf">ToShortDateString</span><span class="p">()</span> <span class="n">@recording</span><span class="p">.</span><span class="n">Date</span><span class="p">.</span><span class="nf">ToShortTimeString</span><span class="p">()&lt;/</span><span class="n">td</span><span class="p">&gt;</span>
                    <span class="p">&lt;</span><span class="n">td</span><span class="p">&gt;</span><span class="n">@recording</span><span class="p">.</span><span class="n">CallerNumber</span><span class="p">&lt;/</span><span class="n">td</span><span class="p">&gt;</span>
                    <span class="p">&lt;</span><span class="n">td</span><span class="p">&gt;</span><span class="n">@recording</span><span class="p">.</span><span class="n">Duration</span><span class="p">&lt;/</span><span class="n">td</span><span class="p">&gt;</span>
                    <span class="p">&lt;</span><span class="n">td</span><span class="p">&gt;</span><span class="n">@recording</span><span class="p">.</span><span class="n">Transcription</span><span class="p">&lt;/</span><span class="n">td</span><span class="p">&gt;</span>
                    <span class="p">&lt;</span><span class="n">td</span><span class="p">&gt;</span><span class="n">@recording</span><span class="p">.</span><span class="n">Status</span><span class="p">&lt;/</span><span class="n">td</span><span class="p">&gt;</span>
                    <span class="p">&lt;</span><span class="n">td</span><span class="p">&gt;</span>
                        <span class="p">&lt;</span><span class="n">audio</span> <span class="n">controls</span><span class="p">&gt;</span>
                            <span class="p">&lt;</span><span class="n">source</span> <span class="n">src</span><span class="p">=</span><span class="err">@</span><span class="p">(</span><span class="s">$"</span><span class="p">{</span><span class="n">Http</span><span class="p">.</span><span class="n">BaseAddress</span><span class="p">}</span><span class="s">/Voicemails/</span><span class="p">{</span><span class="n">recording</span><span class="p">.</span><span class="n">RecordingSID</span><span class="p">}</span><span class="s">.mp3"</span><span class="p">)</span> <span class="n">type</span><span class="p">=</span><span class="s">"audio/mpeg"</span><span class="p">&gt;</span>
                        <span class="p">&lt;/</span><span class="n">audio</span><span class="p">&gt;</span>
                    <span class="p">&lt;/</span><span class="n">td</span><span class="p">&gt;</span>
                    <span class="p">&lt;</span><span class="n">td</span><span class="p">&gt;</span>
                        <span class="p">&lt;</span><span class="n">button</span> <span class="n">type</span><span class="p">=</span><span class="s">"button"</span> <span class="k">class</span><span class="err">="</span><span class="nc">btn</span> <span class="n">btn</span><span class="p">-</span><span class="n">primary</span><span class="s">" @onclick="</span><span class="p">()</span> <span class="p">=&gt;</span> <span class="nf">SaveRecording</span><span class="p">(</span><span class="n">recording</span><span class="p">.</span><span class="n">RecordingSID</span><span class="p">)</span><span class="s">"&gt;Save&lt;/button&gt;
</span>                        <span class="p">&lt;</span><span class="n">button</span> <span class="n">type</span><span class="p">=</span><span class="s">"button"</span> <span class="k">class</span><span class="err">="</span><span class="nc">btn</span> <span class="n">btn</span><span class="p">-</span><span class="n">danger</span><span class="s">" @onclick="</span><span class="p">()</span> <span class="p">=&gt;</span> <span class="nf">DeleteRecording</span><span class="p">(</span><span class="n">recording</span><span class="p">.</span><span class="n">RecordingSID</span><span class="p">)</span><span class="s">"&gt;Delete&lt;/button&gt;
</span>                    <span class="p">&lt;/</span><span class="n">td</span><span class="p">&gt;</span>
                <span class="p">&lt;/</span><span class="n">tr</span><span class="p">&gt;</span>
            <span class="p">}</span>
        <span class="p">&lt;/</span><span class="n">tbody</span><span class="p">&gt;</span>
    <span class="p">&lt;/</span><span class="n">table</span><span class="p">&gt;</span>
<span class="p">}</span>
<span class="n">@code</span> <span class="p">{</span>
    <span class="k">private</span> <span class="n">Recording</span><span class="p">[]?</span> <span class="n">recordingMetadata</span><span class="p">;</span>
    <span class="k">protected</span> <span class="k">override</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">OnInitializedAsync</span><span class="p">()</span> <span class="p">=&gt;</span> <span class="k">await</span> <span class="nf">UpdateTable</span><span class="p">();</span>
    <span class="k">private</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">SaveRecording</span><span class="p">(</span><span class="kt">string</span> <span class="n">recordingSid</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">await</span> <span class="n">Http</span><span class="p">.</span><span class="nf">PatchAsync</span><span class="p">(</span><span class="s">$"/RecordingManagement/Save/</span><span class="p">{</span><span class="n">recordingSid</span><span class="p">}</span><span class="s">"</span><span class="p">,</span> <span class="k">null</span><span class="p">);</span>
        <span class="k">await</span> <span class="nf">UpdateTable</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="k">private</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">DeleteRecording</span><span class="p">(</span><span class="kt">string</span> <span class="n">recordingSid</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">await</span> <span class="n">Http</span><span class="p">.</span><span class="nf">DeleteAsync</span><span class="p">(</span><span class="s">$"/RecordingManagement/Delete/</span><span class="p">{</span><span class="n">recordingSid</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
        <span class="k">await</span> <span class="nf">UpdateTable</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="k">private</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">UpdateTable</span><span class="p">()</span> <span class="p">=&gt;</span> <span class="n">recordingMetadata</span> <span class="p">=</span> <span class="k">await</span> <span class="n">Http</span><span class="p">.</span><span class="n">GetFromJsonAsync</span><span class="p">&lt;</span><span class="n">Recording</span><span class="p">[</span><span class="k">]&gt;</span><span class="p">(</span><span class="s">"/RecordingManagement/Index"</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This page calls the newly created API endpoints to get the list of Recording objects and save/delete operations.</p>

<p>Create a new class called <code class="language-plaintext highlighter-rouge">Recording</code> inside the Blazor project as well, and paste the same code as the API:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">VoicemailDirectory.Console</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">Recording</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="kt">int</span> <span class="n">Id</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="n">RecordingSID</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="n">DateTime</span> <span class="n">Date</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="n">TimeSpan</span> <span class="n">Duration</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="n">CallerNumber</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="n">Transcription</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="n">RecordingStatus</span> <span class="n">Status</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
<span class="p">}</span>
<span class="k">public</span> <span class="k">enum</span> <span class="n">RecordingStatus</span>
<span class="p">{</span>
    <span class="n">New</span><span class="p">,</span>
    <span class="n">Saved</span>
<span class="p">}</span>
</code></pre></div></div>

<p>!!!info</p>

<p>Instead of duplicating the code this way, you can create a class library with the data classes and share between the API and the Blazor app. This tutorial creates a copy of the recording class to keep things simple.</p>

<p>!!!</p>

<p>For this setup to work, you also need to update the CORS policy of your API. Even though both applications run locally, they run on different ports and thus on different <a href="https://developer.mozilla.org/en-US/docs/Glossary/Origin">origins</a>. For security reasons, browsers don’t allow one origin to send HTTP requests to other origins. You can provide Cross-Origin Resource Sharing (CORS) headers from your API project to tell the browser which origins are allowed to send HTTP requests to the API project. So in order for Blazor to call the API, you need to enable a permissive CORS policy in the API project.</p>

<p>!!!info</p>

<p>Sometimes in development environments, and especially in production, a reverse proxy is used to host the front-end and the back-end on a single origin, thus avoiding the need for CORS. Here’s a tutorial that shows you <a href="https://swimburger.net/blog/dotnet/use-yarp-to-host-client-and-api-server-on-a-single-origin">how to host a client and API on a single origin using YARP, Microsoft’s ASP.NET Core based reverse proxy</a>.</p>

<p>!!!</p>

<p>Open the Program.cs file in the API project and the highlighted lines as shown below:</p>

<p>```csharp hl_lines=”3 4 5 6 7 8 9”
builder.Services.AddDbContext<RecordingContext>(options =&gt; options.UseSqlite($"Data Source={dbPath}"));
builder.Services.AddCors(policy =&gt;
{
    policy.AddPolicy("CorsPolicy", opt =&gt; opt
        .AllowAnyOrigin()
        .AllowAnyHeader()
        .AllowAnyMethod());
});
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();</RecordingContext></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
!!!warning

Do not allow any origin (`AllowAnyOrigin()`) in production. Instead, explicitly configure the individual origins you'd like to allow.

!!!

And the following line to enable the policy:

```csharp hl_lines="3"
app.MapControllers();
app.UseCors("CorsPolicy");
app.Run();
</code></pre></div></div>

<p>Then, restart your API for your changes to take effect.</p>

<p>So the final step is to run the front-end test the functionality using the Blazor WebAssembly front-end.</p>

<h2 id="test-via-the-front-end">Test via the front-end</h2>

<p>While your API is still running, open another terminal inside the console application and run it with the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet run
</code></pre></div></div>

<p>Open a browser and go to your application at http://localhost:{YOUR APPLICATION’S PORT}.</p>

<p>It should look like this:</p>

<p><img src="/images/vpblogimg/2026/08/Build-a-Voicemail-Inbox-using-Twilio-Voice-and-Blazor-Part-2/04.png" alt="Build a Voicemail Inbox using Twilio Voice and Blazor (Part 2) - image 4" /></p>

<p>If you have lots of messages, you can play them very quickly as the play button for all of them will be directly accessible to you, whereas if you tried to achieve the same via the voice UI, you would have to go through all the messages one-by-one. Also, you can see the transcriptions, so you may not even want to play the messages in the first place.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this tutorial, you implemented using a SQLite database with EF Core, and also implemented your Blazor WebAssembly front-end to add more functionality to your voicemail service. I hope you enjoyed following this tutorial as much as I enjoyed writing it.</p>

<p>If you’d like to keep learning, I recommend taking a look at these articles:</p>

<ul>
  <li>
    <p><a href="https://www.twilio.com/blog/integrate-amazon-cognito-authentication-with-hosted-ui-in-aspdotnet-core">How to Integrate Amazon Cognito Authentication with Hosted UI in ASP.NET Core</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/build-a-chatgpt-sms-bot-with-azure-openai-service-and-aspdotnet-core">Build a ChatGPT SMS bot with Azure OpenAI Service and ASP.NET Core</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/transcribe-phone-calls-in-real-time-with-twilio-vosk-and-aspdotnet-core">Transcribe phone calls in real time with Twilio, Vosk, and ASP.NET Core</a></p>
  </li>
</ul>
]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[Build a Voicemail Inbox using Twilio Voice and ASP.NET Core]]></title>
    <link href="https://volkanpaksoy.com/archive/2026/08/05/Build-a-Voicemail-Inbox-using-Twilio-Voice-and-ASPNET-Core/"/>
    <updated>2026-08-05T12:10:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2026/08/05/Build-a-Voicemail-Inbox-using-Twilio-Voice-and-ASPNET-Core</id>
    <content type="html"><![CDATA[<blockquote>
  <p>This article was originally published on the <a href="https://www.twilio.com/en-us/blog/build-a-voicemail-service-using-twilio-voice-and-aspdotnet-core">Twilio Blog</a>.</p>
</blockquote>

<p>Most mobile networks provide a voicemail service letting people who call you leave a message when you can’t answer, and letting you listen to those messages by calling your voicemail number. When you have programmatic access to your calls using Twilio, you can implement your voicemail service and customize it to you or your business’s needs.</p>

<p>In this tutorial, you will learn how to implement an Interactive Voice Response (IVR) app using ASP.NET Core Web API project to record and serve your voicemails and how to manage them (play/save/delete) by calling your Twilio phone number.</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>You’ll need the following things in this tutorial:</p>

<ul>
  <li>
    <p>A free <a href="https://www.twilio.com/try-twilio">Twilio account</a></p>
  </li>
  <li>
    <p>A <a href="https://support.twilio.com/hc/en-us/articles/223135247-How-to-Search-for-and-Buy-a-Twilio-Phone-Number-from-Console">Twilio phone number</a></p>
  </li>
  <li>
    <p>An OS that supports .NET (Windows/macOS/Linux)</p>
  </li>
  <li>
    <p><a href="https://dotnet.microsoft.com/en-us/download/dotnet/7.0">.NET 7.0 SDK</a> (newer and older versions may work too)</p>
  </li>
  <li>
    <p>A code editor or IDE (Recommended: <a href="https://code.visualstudio.com/Download">Visual Studio Code</a> with <a href="https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp">the C# plugin</a>, <a href="https://visualstudio.microsoft.com/">Visual Studio</a>, or <a href="https://www.jetbrains.com/rider/">JetBrains Rider</a>)</p>
  </li>
  <li>
    <p><a href="https://ngrok.com/">ngrok</a> (A <a href="https://dashboard.ngrok.com/signup">free ngrok account</a> is sufficient for this tutorial)</p>
  </li>
  <li>
    <p>​​<a href="https://git-scm.com/downloads">Git CLI</a></p>
  </li>
  <li>
    <p>Experience with ASP.NET Core, <a href="https://www.twilio.com/docs/usage/webhooks/voice-webhooks">the Twilio voice webhook</a> and <a href="https://www.twilio.com/docs/voice/twiml">TwiML</a></p>
  </li>
</ul>

<h2 id="project-overview">Project overview</h2>

<p>In this tutorial, you will implement an ASP.NET Core Web API to record and manage your voicemails. Before getting into the implementation details, let’s take a look at what it does:</p>

<p>The flow of your IVR starts when someone calls your Twilio phone number which Twilio picks up.  Twilio passes the call details to your Web API and expects TwiML instructions to manage the call. If the caller’s phone number is one of the “owners” phone numbers, the voicemail directory flow will be invoked, otherwise the voicemail recording flow will be invoked.</p>

<p>Voicemail recording flow:</p>

<ul>
  <li>
    <p>The IVR asks the caller to leave a message after the beep, after which the call is being recorded.</p>
  </li>
  <li>
    <p>Once the caller finishes recording, the IVR thanks the caller for leaving a message and ends the call.</p>
  </li>
  <li>
    <p>When Twilio saves the recording, Twilio will notify your Web API of the location of the recording and your Web API downloads the recording to disk.</p>
  </li>
</ul>

<p>Voicemail directory flow:</p>

<ul>
  <li>
    <p>The IVR informs the user how many new and saved messages are available, and creates a queue of the messages</p>
  </li>
  <li>
    <p>The IVR plays the current message in the queue and asks to press the dial pad buttons to either 1-replay, 2-save, or 3-delete the message.</p>
  </li>
  <li>
    <p>If 1 is pressed, the message and the queue are not changed. If 2 is pressed, the message is saved, and then the message is removed from the queue. If 3 is pressed, the message is deleted, and then the message removed from the queue. If there are more messages in the queue, go to step 7.</p>
  </li>
  <li>
    <p>The IVR informs the caller that there are no more messages and ends the call.</p>
  </li>
</ul>

<p>Now that you are more familiar with the end result, let’s get started.</p>

<h2 id="project-set-up">Project set up</h2>

<p>The easiest way to set up the starter project is by cloning the sample <a href="https://github.com/Dev-Power/voicemail-service-using-twilio">GitHub repository</a>.</p>

<p>Open a terminal, change to the directory you want to download the project and run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/Dev-Power/voicemail-service-using-twilio.git <span class="nt">--branch</span> starter-project
</code></pre></div></div>

<p>The project can be found in the src\VoicemailDirectory.WebApi subfolder. Open the project in your IDE.</p>

<p>The project comes with empty files that you will implement as you go along. Before starting the implementation, let’s take a look at the key points in the starter project:</p>

<p>The API will download the audio recordings and store them under the wwwroot/Voicemails directory. To let Twilio have access to these files, static file hosting is enabled in the API by adding the following line to the Program.cs file:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">app</span><span class="p">.</span><span class="nf">UseStaticFiles</span><span class="p">();</span>
</code></pre></div></div>

<p>!!!warning</p>

<p>The voicemail recordings will be stored on your web server and served as static files, meaning that anyone would be able to download them. In production, you should validate that the incoming HTTP requests, requesting these static files originate from Twilio, and not someone else. Follow the <a href="https://github.com/twilio-labs/twilio-aspnet#validate-twilio-http-requests">documentation for the Twilio helper library for ASP.NET on how to validate Twilio requests for static files and your webhooks</a>.</p>

<p>!!!</p>

<p>The downloading operation and all local I/O operations will be handled via <code class="language-plaintext highlighter-rouge">FileService</code>, which is added to <a href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-7.0">the IoC</a> by the following statements in Program.cs:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddHttpClient</span><span class="p">();</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddTransient</span><span class="p">&lt;</span><span class="n">FileService</span><span class="p">&gt;();</span>
</code></pre></div></div>

<p>As explained in the project overview section, you must add the owner’s phones to the configuration to access your voicemails. You can only listen to your voicemails by calling from one of those numbers.</p>

<p>Update your configuration by adding your phone number(s) to the appsettings.json file:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="w"> </span><span class="nl">"Voicemail"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"Owners"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="s2">"{ YOUR PHONE NUMBER }"</span><span class="w">
    </span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Next, for Twilio to be able to send HTTP requests to your Web API, you’ll need to make your locally running Web API publicly available over the internet. You can use ngrok for this, a free secure tunneling service. Run the following command to create a tunnel with ngrok:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ngrok http http://localhost:5096
</code></pre></div></div>

<p>!!!info</p>

<p>http://localhost:5096 is the local URL that your Web API will listen to for HTTP requests.</p>

<p>!!!</p>

<p>Note the forwarding URL on your screen, which should look like this:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Forwarding                    https://<span class="o">{</span>random-url<span class="o">}</span>.<span class="o">{</span>region<span class="o">}</span>.ngrok.io -&gt; http://localhost:5096
</code></pre></div></div>

<p>Ngrok has now created a secure tunnel that will accept HTTP requests at the temporary Forwarding URL and forward those requests to http://localhost:5096.</p>

<p>When your Twilio phone number receives a phone call, Twilio will send an HTTP request to your Web API passing in the call information and expecting instructions. You need to configure where this HTTP request is sent when a call comes in.</p>

<p>To do this, go to the <a href="https://www.twilio.com/console">Twilio Console</a>, select your account, and then click Phone Numbers → Manage → Active Numbers on the left pane. (If Phone Numbers isn’t on the left pane, click on Explore Products and then on Phone Numbers.)</p>

<p>Click on the phone number you want to use for your project and scroll down to the Voice section.</p>

<p>Under the A Call Comes In label, set the dropdown to Webhook, the text field next to it to the ngrok Forwarding URL suffixed with the /IncomingCall path, the next dropdown to HTTP POST, and click Save. It should look like this:</p>

<p><img src="/images/vpblogimg/2026/08/Build-a-Voicemail-Inbox-using-Twilio-Voice-and-ASPNET-Core/01.png" alt="Build a Voicemail Inbox using Twilio Voice and ASP.NET Core - image 1" /></p>

<p>Note that you have to use HTTPS as the protocol when setting the webhook URL.</p>

<p>Now that all the plumbing is done, move on to the next section to implement the application.</p>

<h2 id="project-implementation">Project implementation</h2>

<p>The first step to recording voicemails is to receive the calls. As you added the /IncomingCall path to your webhook URL, your controller must match this.</p>

<p>Update the IncomingCallController.cs file with the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Mvc</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.Extensions.Options</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.AspNet.Core</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.TwiML</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">VoicemailDirectory.WebApi.Controllers</span><span class="p">;</span>
<span class="p">[</span><span class="n">ApiController</span><span class="p">]</span>
<span class="p">[</span><span class="nf">Route</span><span class="p">(</span><span class="s">"[controller]"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">IncomingCallController</span> <span class="p">:</span> <span class="n">TwilioController</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">VoicemailOptions</span> <span class="n">_voicemailOptions</span><span class="p">;</span>
    <span class="k">public</span> <span class="nf">IncomingCallController</span><span class="p">(</span><span class="n">IOptionsSnapshot</span><span class="p">&lt;</span><span class="n">VoicemailOptions</span><span class="p">&gt;</span> <span class="n">voicemailOptions</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_voicemailOptions</span> <span class="p">=</span> <span class="n">voicemailOptions</span><span class="p">.</span><span class="n">Value</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="p">[</span><span class="n">HttpPost</span><span class="p">]</span>
    <span class="k">public</span> <span class="n">TwiMLResult</span> <span class="nf">Index</span><span class="p">([</span><span class="n">FromForm</span><span class="p">]</span> <span class="kt">string</span> <span class="k">from</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">VoiceResponse</span><span class="p">();</span>
        <span class="kt">var</span> <span class="n">redirectUrl</span> <span class="p">=</span> <span class="n">_voicemailOptions</span><span class="p">.</span><span class="n">Owners</span><span class="p">.</span><span class="nf">Contains</span><span class="p">(</span><span class="k">from</span><span class="p">)</span>
            <span class="p">?</span> <span class="n">Url</span><span class="p">.</span><span class="nf">Action</span><span class="p">(</span><span class="s">"Index"</span><span class="p">,</span> <span class="s">"Directory"</span><span class="p">)!</span>
            <span class="p">:</span> <span class="n">Url</span><span class="p">.</span><span class="nf">Action</span><span class="p">(</span><span class="s">"Index"</span><span class="p">,</span> <span class="s">"Record"</span><span class="p">)!;</span>
        <span class="n">response</span><span class="p">.</span><span class="nf">Redirect</span><span class="p">(</span>
            <span class="n">url</span><span class="p">:</span> <span class="k">new</span> <span class="nf">Uri</span><span class="p">(</span><span class="n">redirectUrl</span><span class="p">,</span> <span class="n">UriKind</span><span class="p">.</span><span class="n">Relative</span><span class="p">),</span>
            <span class="n">method</span><span class="p">:</span> <span class="n">Twilio</span><span class="p">.</span><span class="n">Http</span><span class="p">.</span><span class="n">HttpMethod</span><span class="p">.</span><span class="n">Post</span>
        <span class="p">);</span>
        <span class="k">return</span> <span class="nf">TwiML</span><span class="p">(</span><span class="n">response</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The default action (<code class="language-plaintext highlighter-rouge">Index</code>) is invoked when somebody calls your Twilio phone number. The calling number is checked here to see if the caller is an owner or anybody else. If the caller is unknown, it <a href="https://www.twilio.com/docs/voice/twiml/redirect">redirects</a> to the <code class="language-plaintext highlighter-rouge">RecordController</code>’s <code class="language-plaintext highlighter-rouge">Index</code> action. If the caller is an owner, it redirects to the <code class="language-plaintext highlighter-rouge">DirectoryController</code>’s <code class="language-plaintext highlighter-rouge">Index</code> action.</p>

<p>!!!info</p>

<p>Using <a href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?view=aspnetcore-7.0#options-interfaces">IOptionsSnapshot</a> allows you to update the phone numbers configured in appsettings.json in the <code class="language-plaintext highlighter-rouge">Voicemail:Owners</code> array without having to stop the application. This is especially useful when testing because you can quickly change the mode of operation by updating the configuration.</p>

<p>!!!</p>

<p>Update the RecordController.cs with the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Mvc</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.AspNet.Core</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.TwiML</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">VoicemailDirectory.WebApi.Services</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">VoicemailDirectory.WebApi.Controllers</span><span class="p">;</span>
<span class="p">[</span><span class="n">ApiController</span><span class="p">]</span>
<span class="p">[</span><span class="nf">Route</span><span class="p">(</span><span class="s">"[controller]/[action]"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">RecordController</span> <span class="p">:</span> <span class="n">TwilioController</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">ILogger</span><span class="p">&lt;</span><span class="n">IncomingCallController</span><span class="p">&gt;</span> <span class="n">_logger</span><span class="p">;</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">FileService</span> <span class="n">_fileService</span><span class="p">;</span>
    <span class="k">public</span> <span class="nf">RecordController</span><span class="p">(</span>
        <span class="n">ILogger</span><span class="p">&lt;</span><span class="n">IncomingCallController</span><span class="p">&gt;</span> <span class="n">logger</span><span class="p">,</span>
        <span class="n">FileService</span> <span class="n">fileService</span>
    <span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_logger</span> <span class="p">=</span> <span class="n">logger</span><span class="p">;</span>
        <span class="n">_fileService</span> <span class="p">=</span> <span class="n">fileService</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="p">[</span><span class="n">HttpPost</span><span class="p">]</span>
    <span class="k">public</span> <span class="n">TwiMLResult</span> <span class="nf">Index</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">VoiceResponse</span><span class="p">();</span>
        <span class="n">response</span><span class="p">.</span><span class="nf">Say</span><span class="p">(</span><span class="s">"Hello, please leave a message after the beep."</span><span class="p">);</span>
        <span class="n">response</span><span class="p">.</span><span class="nf">Record</span><span class="p">(</span>
            <span class="n">timeout</span><span class="p">:</span> <span class="m">10</span><span class="p">,</span>
            <span class="n">action</span><span class="p">:</span> <span class="k">new</span> <span class="nf">Uri</span><span class="p">(</span><span class="n">Url</span><span class="p">.</span><span class="nf">Action</span><span class="p">(</span><span class="s">"Bye"</span><span class="p">)!,</span> <span class="n">UriKind</span><span class="p">.</span><span class="n">Relative</span><span class="p">),</span>
            <span class="n">method</span><span class="p">:</span> <span class="n">Twilio</span><span class="p">.</span><span class="n">Http</span><span class="p">.</span><span class="n">HttpMethod</span><span class="p">.</span><span class="n">Post</span><span class="p">,</span>
            <span class="n">recordingStatusCallback</span><span class="p">:</span> <span class="k">new</span> <span class="nf">Uri</span><span class="p">(</span><span class="n">Url</span><span class="p">.</span><span class="nf">Action</span><span class="p">(</span><span class="s">"RecordingStatus"</span><span class="p">)!,</span> <span class="n">UriKind</span><span class="p">.</span><span class="n">Relative</span><span class="p">),</span>
            <span class="n">recordingStatusCallbackMethod</span><span class="p">:</span> <span class="n">Twilio</span><span class="p">.</span><span class="n">Http</span><span class="p">.</span><span class="n">HttpMethod</span><span class="p">.</span><span class="n">Post</span>
        <span class="p">);</span>
        <span class="k">return</span> <span class="nf">TwiML</span><span class="p">(</span><span class="n">response</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="p">[</span><span class="n">HttpPost</span><span class="p">]</span>
    <span class="k">public</span> <span class="n">TwiMLResult</span> <span class="nf">Bye</span><span class="p">()</span> <span class="p">=&gt;</span> <span class="k">new</span> <span class="nf">VoiceResponse</span><span class="p">()</span>
        <span class="p">.</span><span class="nf">Say</span><span class="p">(</span><span class="s">"Thank you for leaving a message, goodbye."</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">ToTwiMLResult</span><span class="p">();</span>
    <span class="p">[</span><span class="n">HttpPost</span><span class="p">]</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">RecordingStatus</span><span class="p">(</span>
        <span class="p">[</span><span class="n">FromForm</span><span class="p">]</span> <span class="kt">string</span> <span class="n">callSid</span><span class="p">,</span>
        <span class="p">[</span><span class="n">FromForm</span><span class="p">]</span> <span class="kt">string</span> <span class="n">recordingUrl</span><span class="p">,</span>
        <span class="p">[</span><span class="n">FromForm</span><span class="p">]</span> <span class="kt">string</span> <span class="n">recordingSid</span><span class="p">,</span>
        <span class="p">[</span><span class="n">FromForm</span><span class="p">]</span> <span class="kt">string</span> <span class="n">recordingStatus</span>
    <span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_logger</span><span class="p">.</span><span class="nf">LogInformation</span><span class="p">(</span>
            <span class="s">"Recording status changed to {recordingStatus} for call {callSid}. Recording is available at {recordingUrl}"</span><span class="p">,</span>
            <span class="n">recordingStatus</span><span class="p">,</span> <span class="n">callSid</span><span class="p">,</span> <span class="n">recordingUrl</span>
        <span class="p">);</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">recordingStatus</span> <span class="p">==</span> <span class="s">"completed"</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">await</span> <span class="n">_fileService</span><span class="p">.</span><span class="nf">DownloadRecording</span><span class="p">(</span><span class="n">recordingUrl</span><span class="p">,</span> <span class="n">recordingSid</span><span class="p">);</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Recording the voicemails is a 3-step process:</p>

<ul>
  <li>
    <p>Respond with TwiML instruction to tell Twilio  to <a href="https://www.twilio.com/docs/voice/twiml/record">record</a> the call, which is done in the <code class="language-plaintext highlighter-rouge">Index</code> action.</p>
  </li>
  <li>
    <p>When the caller is done recording, Twilio will send an HTTP request to the <code class="language-plaintext highlighter-rouge">Bye</code> action, because that’s the <code class="language-plaintext highlighter-rouge">action</code> URL configured on the <code class="language-plaintext highlighter-rouge">Record</code> TwiML. The <code class="language-plaintext highlighter-rouge">Bye</code> action will acknowledge the message has been taken to the caller, and because there’s no further TwiML instructions, the call will be ended.</p>
  </li>
  <li>
    <p>When the recording status changes, Twilio will send an HTTP request to the <code class="language-plaintext highlighter-rouge">RecordingStatus</code> action, because that’s the <code class="language-plaintext highlighter-rouge">recordingStatusCallback</code> URL configured on the <code class="language-plaintext highlighter-rouge">Record</code> TwiML.  The HTTP request sent to the <code class="language-plaintext highlighter-rouge">RecordingStatus</code> action will contain data such as the recording URL, SID, status, and more. If the status is <code class="language-plaintext highlighter-rouge">completed</code>, the action will download the audio recording and store it locally.</p>
  </li>
</ul>

<p>The caller may hang up before reaching the timeout, in which case they will not hear the acknowledgement message, but the recording will still be downloaded.</p>

<p>!!!warning</p>

<p>By default, Recording URLs don’t require authentication, and recordings are not encrypted. However, you can require basic authentication to access the recordings and <a href="https://www.twilio.com/docs/voice/tutorials/voice-recording-encryption">configure recordings to be encrypted</a> in the voice settings (Voice → Settings → General). If you enable basic authentication, you’ll need to provide the Twilio Account SID and Auth Token, or API Key SID and API Key Secret as the username and password. This tutorial assumes basic authentication is not enabled for recordings.</p>

<p>!!!</p>

<p>Next, update the third and final controller, <code class="language-plaintext highlighter-rouge">DirectoryController</code>, with the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Mvc</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.AspNet.Core</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.TwiML</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.TwiML.Voice</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">VoicemailDirectory.WebApi.Services</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">VoicemailDirectory.WebApi.Controllers</span><span class="p">;</span>
<span class="p">[</span><span class="n">ApiController</span><span class="p">]</span>
<span class="p">[</span><span class="nf">Route</span><span class="p">(</span><span class="s">"[controller]/[action]"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">DirectoryController</span> <span class="p">:</span> <span class="n">TwilioController</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">ILogger</span><span class="p">&lt;</span><span class="n">DirectoryController</span><span class="p">&gt;</span> <span class="n">_logger</span><span class="p">;</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">FileService</span> <span class="n">_fileService</span><span class="p">;</span>
    <span class="k">public</span> <span class="nf">DirectoryController</span><span class="p">(</span>
        <span class="n">ILogger</span><span class="p">&lt;</span><span class="n">DirectoryController</span><span class="p">&gt;</span> <span class="n">logger</span><span class="p">,</span>
        <span class="n">FileService</span> <span class="n">fileService</span>
    <span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_logger</span> <span class="p">=</span> <span class="n">logger</span><span class="p">;</span>
        <span class="n">_fileService</span> <span class="p">=</span> <span class="n">fileService</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="p">[</span><span class="n">HttpPost</span><span class="p">]</span>
    <span class="k">public</span> <span class="n">TwiMLResult</span> <span class="nf">Index</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">newMessages</span> <span class="p">=</span> <span class="n">_fileService</span><span class="p">.</span><span class="nf">GetRecordingSids</span><span class="p">(</span><span class="n">Constants</span><span class="p">.</span><span class="n">New</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">savedMessages</span> <span class="p">=</span> <span class="n">_fileService</span><span class="p">.</span><span class="nf">GetRecordingSids</span><span class="p">(</span><span class="n">Constants</span><span class="p">.</span><span class="n">Saved</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">VoiceResponse</span><span class="p">();</span>
        <span class="kt">string</span> <span class="nf">GetWordingSingularOrPlural</span><span class="p">(</span><span class="kt">int</span> <span class="n">messageCount</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="n">messageCount</span> <span class="p">==</span> <span class="m">1</span> <span class="p">?</span> <span class="s">"message"</span> <span class="p">:</span> <span class="s">"messages"</span><span class="p">;</span>
        <span class="kt">string</span> <span class="nf">GetNumberOrNo</span><span class="p">(</span><span class="kt">int</span> <span class="n">messageCount</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="n">messageCount</span> <span class="p">==</span> <span class="m">0</span> <span class="p">?</span> <span class="s">"no"</span> <span class="p">:</span> <span class="n">messageCount</span><span class="p">.</span><span class="nf">ToString</span><span class="p">();</span>
        <span class="n">response</span><span class="p">.</span><span class="nf">Say</span><span class="p">(</span>
            <span class="s">$"Hello, you have </span><span class="p">{</span><span class="nf">GetNumberOrNo</span><span class="p">(</span><span class="n">newMessages</span><span class="p">.</span><span class="n">Count</span><span class="p">)}</span><span class="s"> new </span><span class="p">{</span><span class="nf">GetWordingSingularOrPlural</span><span class="p">(</span><span class="n">newMessages</span><span class="p">.</span><span class="n">Count</span><span class="p">)}</span><span class="s"> "</span> <span class="p">+</span>
            <span class="s">$"and </span><span class="p">{</span><span class="nf">GetNumberOrNo</span><span class="p">(</span><span class="n">savedMessages</span><span class="p">.</span><span class="n">Count</span><span class="p">)}</span><span class="s"> saved </span><span class="p">{</span><span class="nf">GetWordingSingularOrPlural</span><span class="p">(</span><span class="n">savedMessages</span><span class="p">.</span><span class="n">Count</span><span class="p">)}</span><span class="s">. "</span>
        <span class="p">);</span>
        <span class="c1">// If there are no new or saved messages, end the call</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">newMessages</span><span class="p">.</span><span class="n">Count</span> <span class="p">==</span> <span class="m">0</span> <span class="p">&amp;&amp;</span> <span class="n">savedMessages</span><span class="p">.</span><span class="n">Count</span> <span class="p">==</span> <span class="m">0</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="n">response</span><span class="p">.</span><span class="nf">Say</span><span class="p">(</span><span class="s">"Goodbye!"</span><span class="p">);</span>
            <span class="k">return</span> <span class="nf">TwiML</span><span class="p">(</span><span class="n">response</span><span class="p">);</span>
        <span class="p">}</span>
        <span class="c1">// Start with the new messages if there are any</span>
        <span class="kt">string</span> <span class="n">recordingType</span> <span class="p">=</span> <span class="n">newMessages</span><span class="p">.</span><span class="n">Count</span> <span class="p">&gt;</span> <span class="m">0</span> <span class="p">?</span> <span class="n">Constants</span><span class="p">.</span><span class="n">New</span> <span class="p">:</span> <span class="n">Constants</span><span class="p">.</span><span class="n">Saved</span><span class="p">;</span>
        <span class="n">response</span><span class="p">.</span><span class="nf">Say</span><span class="p">(</span><span class="s">$"Playing </span><span class="p">{</span><span class="n">recordingType</span><span class="p">}</span><span class="s"> messages."</span><span class="p">);</span>
        <span class="c1">// No filter to get all recordings. Order alphabetically so that the new ones come at top</span>
        <span class="c1">// Can prepend datetime as well to order more precisely</span>
        <span class="kt">var</span> <span class="n">allMessages</span> <span class="p">=</span> <span class="n">_fileService</span><span class="p">.</span><span class="nf">GetRecordingSids</span><span class="p">(</span><span class="kt">string</span><span class="p">.</span><span class="n">Empty</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">OrderBy</span><span class="p">(</span><span class="n">s</span> <span class="p">=&gt;</span> <span class="n">s</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">ToList</span><span class="p">();</span>
        <span class="n">response</span><span class="p">.</span><span class="nf">Append</span><span class="p">(</span>
            <span class="nf">CreateGatherTwiml</span><span class="p">(</span><span class="n">allMessages</span><span class="p">)</span>
                <span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="nf">PlayNextMessage</span><span class="p">(</span><span class="n">allMessages</span><span class="p">))</span>
                <span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="nf">SayOptions</span><span class="p">())</span>
        <span class="p">);</span>
        <span class="k">return</span> <span class="nf">TwiML</span><span class="p">(</span><span class="n">response</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="p">[</span><span class="n">HttpPost</span><span class="p">]</span>
    <span class="k">public</span> <span class="n">TwiMLResult</span> <span class="nf">Gather</span><span class="p">(</span>
        <span class="p">[</span><span class="n">FromQuery</span><span class="p">]</span> <span class="n">List</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="n">queuedMessages</span><span class="p">,</span>
        <span class="p">[</span><span class="n">FromForm</span><span class="p">]</span> <span class="kt">int</span> <span class="n">digits</span>
    <span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_logger</span><span class="p">.</span><span class="nf">LogInformation</span><span class="p">(</span>
            <span class="s">"QueuedMessages: {queuedMessages}, user entered: {digits}"</span><span class="p">,</span>
            <span class="n">queuedMessages</span><span class="p">,</span> <span class="n">digits</span>
        <span class="p">);</span>
        <span class="kt">var</span> <span class="n">currentMessage</span> <span class="p">=</span> <span class="n">queuedMessages</span><span class="p">.</span><span class="nf">First</span><span class="p">();</span>
        <span class="kt">var</span> <span class="n">isCurrentMessageNew</span> <span class="p">=</span> <span class="n">currentMessage</span><span class="p">.</span><span class="nf">StartsWith</span><span class="p">(</span><span class="n">Constants</span><span class="p">.</span><span class="n">New</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">VoiceResponse</span><span class="p">();</span>
        <span class="k">switch</span> <span class="p">(</span><span class="n">digits</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">case</span> <span class="m">1</span><span class="p">:</span> <span class="c1">// Replay</span>
                <span class="c1">// No action. The existing message will stay at the top of the queue to be replayed</span>
                <span class="k">break</span><span class="p">;</span>
            <span class="k">case</span> <span class="m">2</span><span class="p">:</span> <span class="c1">// Save</span>
                <span class="n">_fileService</span><span class="p">.</span><span class="nf">SaveRecording</span><span class="p">(</span><span class="n">currentMessage</span><span class="p">);</span>
                <span class="n">queuedMessages</span><span class="p">.</span><span class="nf">Remove</span><span class="p">(</span><span class="n">currentMessage</span><span class="p">);</span>
                <span class="k">break</span><span class="p">;</span>
            <span class="k">case</span> <span class="m">3</span><span class="p">:</span> <span class="c1">// Delete</span>
                <span class="n">_fileService</span><span class="p">.</span><span class="nf">DeleteRecording</span><span class="p">(</span><span class="n">currentMessage</span><span class="p">);</span>
                <span class="n">queuedMessages</span><span class="p">.</span><span class="nf">Remove</span><span class="p">(</span><span class="n">currentMessage</span><span class="p">);</span>
                <span class="k">break</span><span class="p">;</span>
            <span class="k">default</span><span class="p">:</span> <span class="c1">// Invalid key. Play error message then say the valid options again.</span>
                <span class="n">response</span><span class="p">.</span><span class="nf">Say</span><span class="p">(</span><span class="s">"Sorry, that key is not valid."</span><span class="p">);</span>
                <span class="n">response</span><span class="p">.</span><span class="nf">Append</span><span class="p">(</span>
                    <span class="nf">CreateGatherTwiml</span><span class="p">(</span><span class="n">queuedMessages</span><span class="p">)</span>
                        <span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="nf">SayOptions</span><span class="p">())</span>
                <span class="p">);</span>
                <span class="k">return</span> <span class="nf">TwiML</span><span class="p">(</span><span class="n">response</span><span class="p">);</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">queuedMessages</span><span class="p">.</span><span class="n">Count</span> <span class="p">==</span> <span class="m">0</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="n">response</span><span class="p">.</span><span class="nf">Say</span><span class="p">(</span><span class="s">"No more messages. Goodbye!"</span><span class="p">);</span>
            <span class="k">return</span> <span class="nf">TwiML</span><span class="p">(</span><span class="n">response</span><span class="p">);</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">isCurrentMessageNew</span> <span class="p">&amp;&amp;</span> <span class="n">queuedMessages</span><span class="p">.</span><span class="nf">First</span><span class="p">().</span><span class="nf">StartsWith</span><span class="p">(</span><span class="n">Constants</span><span class="p">.</span><span class="n">Saved</span><span class="p">))</span>
        <span class="p">{</span>
            <span class="n">response</span><span class="p">.</span><span class="nf">Say</span><span class="p">(</span><span class="s">"No more new messages. Here are your saved messages."</span><span class="p">);</span>
        <span class="p">}</span>
        <span class="n">response</span><span class="p">.</span><span class="nf">Append</span><span class="p">(</span>
            <span class="nf">CreateGatherTwiml</span><span class="p">(</span><span class="n">queuedMessages</span><span class="p">)</span>
                <span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="nf">PlayNextMessage</span><span class="p">(</span><span class="n">queuedMessages</span><span class="p">))</span>
                <span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="nf">SayOptions</span><span class="p">())</span>
        <span class="p">);</span>
        <span class="k">return</span> <span class="nf">TwiML</span><span class="p">(</span><span class="n">response</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">private</span> <span class="n">Gather</span> <span class="nf">CreateGatherTwiml</span><span class="p">(</span><span class="n">List</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="n">queuedMessages</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="k">new</span> <span class="nf">Gather</span><span class="p">(</span>
        <span class="n">input</span><span class="p">:</span> <span class="k">new</span> <span class="n">List</span><span class="p">&lt;</span><span class="n">Gather</span><span class="p">.</span><span class="n">InputEnum</span><span class="p">&gt;</span> <span class="p">{</span><span class="n">Twilio</span><span class="p">.</span><span class="n">TwiML</span><span class="p">.</span><span class="n">Voice</span><span class="p">.</span><span class="n">Gather</span><span class="p">.</span><span class="n">InputEnum</span><span class="p">.</span><span class="n">Dtmf</span><span class="p">},</span>
        <span class="n">timeout</span><span class="p">:</span> <span class="m">5</span><span class="p">,</span>
        <span class="n">numDigits</span><span class="p">:</span> <span class="m">1</span><span class="p">,</span>
        <span class="n">action</span><span class="p">:</span> <span class="k">new</span> <span class="nf">Uri</span><span class="p">(</span>
            <span class="n">Url</span><span class="p">.</span><span class="nf">Action</span><span class="p">(</span><span class="s">"Gather"</span><span class="p">,</span> <span class="k">new</span> <span class="p">{</span><span class="n">queuedMessages</span><span class="p">})!,</span>
            <span class="n">UriKind</span><span class="p">.</span><span class="n">Relative</span>
        <span class="p">),</span>
        <span class="n">method</span><span class="p">:</span> <span class="n">Twilio</span><span class="p">.</span><span class="n">Http</span><span class="p">.</span><span class="n">HttpMethod</span><span class="p">.</span><span class="n">Post</span>
    <span class="p">);</span>
    <span class="k">private</span> <span class="n">Say</span> <span class="nf">SayOptions</span><span class="p">()</span>
        <span class="p">=&gt;</span> <span class="k">new</span> <span class="nf">Say</span><span class="p">(</span><span class="s">"To replay press 1. To save the message press 2. To delete the message press 3."</span><span class="p">);</span>
    <span class="k">private</span> <span class="n">Play</span> <span class="nf">PlayNextMessage</span><span class="p">(</span><span class="n">List</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="n">queuedMessages</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">nextMessage</span> <span class="p">=</span> <span class="n">queuedMessages</span><span class="p">.</span><span class="nf">First</span><span class="p">();</span>
        <span class="k">return</span> <span class="k">new</span> <span class="nf">Play</span><span class="p">(</span><span class="k">new</span> <span class="nf">Uri</span><span class="p">(</span><span class="s">$"/Voicemails/</span><span class="p">{</span><span class="n">nextMessage</span><span class="p">}</span><span class="s">.mp3"</span><span class="p">,</span> <span class="n">UriKind</span><span class="p">.</span><span class="n">Relative</span><span class="p">));</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The response comprises three TwiML verbs:</p>

<ul>
  <li>
    <p><a href="https://www.twilio.com/docs/voice/twiml/gather">Gather</a>: Used to collect caller input which the caller provides by pressing a button in their dial keypad (<a href="https://www.twilio.com/docs/glossary/what-is-dtmf">DTMF</a>)</p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/docs/voice/twiml/play">Play</a>: Used to play audio recordings to the caller</p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/docs/voice/twiml/say">Say</a>: Used to communicate the actions they can take after listening to the recordings.</p>
  </li>
</ul>

<p>!!!info</p>

<p>The queued messages are passed in the query string, so the number of voicemails the system supports is limited by the maximum length of a URL (which is around 2,000 characters). You may bump into issues after approximately 40 - 50 messages if you never delete them. If this is an issue you could store this in cookies, in session, or in some other data store.</p>

<p>!!!</p>

<p>When you call your Twilio phone number as the owner, the <code class="language-plaintext highlighter-rouge">Index</code> action will be executed. You haven’t implemented the <code class="language-plaintext highlighter-rouge">FileService</code> yet (which is next), but from the function names, you can deduce that this action does the following:</p>

<ul>
  <li>
    <p>Get the new and saved recordings separately</p>
  </li>
  <li>
    <p>Prepare a welcome message to indicate how many new and how many saved messages are in the directory</p>
  </li>
  <li>
    <p>Get a list of all recordings and prepare the TwiML response to play the first message in the queue, followed by prompting the available options.</p>
  </li>
</ul>

<p>After the first message is played to the caller and the caller has made their decision, Twilio passes this information to the <code class="language-plaintext highlighter-rouge">Gather</code> action of the controller. Now you have to decide what to do based on the caller’s action, which is what the switch statement in the <code class="language-plaintext highlighter-rouge">Gather</code> action does.</p>

<p>Before going over the logic in this controller, implement the <code class="language-plaintext highlighter-rouge">FileService</code> as well, as the controller uses that service heavily.</p>

<p>Update the FileService.cs file with the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">VoicemailDirectory.WebApi.Services</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">FileService</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">HttpClient</span> <span class="n">_httpClient</span><span class="p">;</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="kt">string</span> <span class="n">_rootVoicemailPath</span><span class="p">;</span>
    <span class="k">public</span> <span class="nf">FileService</span><span class="p">(</span><span class="n">IHttpClientFactory</span> <span class="n">httpClientFactory</span><span class="p">,</span> <span class="n">IWebHostEnvironment</span> <span class="n">webHostEnvironment</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_rootVoicemailPath</span> <span class="p">=</span> <span class="s">$"</span><span class="p">{</span><span class="n">webHostEnvironment</span><span class="p">.</span><span class="n">WebRootPath</span><span class="p">}</span><span class="s">/Voicemails"</span><span class="p">;</span>
        <span class="n">_httpClient</span> <span class="p">=</span> <span class="n">httpClientFactory</span><span class="p">.</span><span class="nf">CreateClient</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">DownloadRecording</span><span class="p">(</span><span class="kt">string</span> <span class="n">recordingUrl</span><span class="p">,</span> <span class="kt">string</span> <span class="n">recordingSid</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">using</span> <span class="nn">HttpResponseMessage</span> <span class="n">response</span> <span class="p">=</span> <span class="k">await</span> <span class="n">_httpClient</span><span class="p">.</span><span class="nf">GetAsync</span><span class="p">(</span><span class="s">$"</span><span class="p">{</span><span class="n">recordingUrl</span><span class="p">}</span><span class="s">.mp3"</span><span class="p">);</span>
        <span class="n">response</span><span class="p">.</span><span class="nf">EnsureSuccessStatusCode</span><span class="p">();</span>
        <span class="k">await</span> <span class="k">using</span> <span class="nn">var</span> <span class="n">fs</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">FileStream</span><span class="p">(</span>
            <span class="s">$"</span><span class="p">{</span><span class="n">_rootVoicemailPath</span><span class="p">}</span><span class="s">/</span><span class="p">{</span><span class="n">Constants</span><span class="p">.</span><span class="n">New</span><span class="p">}</span><span class="s">_</span><span class="p">{</span><span class="n">recordingSid</span><span class="p">}</span><span class="s">.mp3"</span><span class="p">,</span>
            <span class="n">FileMode</span><span class="p">.</span><span class="n">CreateNew</span>
        <span class="p">);</span>
        <span class="k">await</span> <span class="n">response</span><span class="p">.</span><span class="n">Content</span><span class="p">.</span><span class="nf">CopyToAsync</span><span class="p">(</span><span class="n">fs</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="n">List</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">GetRecordingSids</span><span class="p">(</span><span class="kt">string</span> <span class="n">recordingType</span><span class="p">)</span>
        <span class="p">=&gt;</span> <span class="n">Directory</span><span class="p">.</span><span class="nf">GetFiles</span><span class="p">(</span><span class="s">$"</span><span class="p">{</span><span class="n">_rootVoicemailPath</span><span class="p">}</span><span class="s">/"</span><span class="p">,</span> <span class="s">$"</span><span class="p">{</span><span class="n">recordingType</span><span class="p">}</span><span class="s">*.mp3"</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">Select</span><span class="p">(</span><span class="n">s</span> <span class="p">=&gt;</span> <span class="n">Path</span><span class="p">.</span><span class="nf">GetFileNameWithoutExtension</span><span class="p">(</span><span class="n">s</span><span class="p">))</span>
            <span class="p">.</span><span class="nf">ToList</span><span class="p">();</span>
    <span class="k">public</span> <span class="k">void</span> <span class="nf">SaveRecording</span><span class="p">(</span><span class="kt">string</span> <span class="n">recordingSid</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">currentPath</span> <span class="p">=</span> <span class="nf">GetRecordingPathBySid</span><span class="p">(</span><span class="n">recordingSid</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">newPath</span> <span class="p">=</span> <span class="n">currentPath</span><span class="p">.</span><span class="nf">Replace</span><span class="p">(</span><span class="s">$"</span><span class="p">{</span><span class="n">Constants</span><span class="p">.</span><span class="n">New</span><span class="p">}</span><span class="s">"</span><span class="p">,</span> <span class="s">$"</span><span class="p">{</span><span class="n">Constants</span><span class="p">.</span><span class="n">Saved</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
        <span class="n">File</span><span class="p">.</span><span class="nf">Move</span><span class="p">(</span><span class="n">currentPath</span><span class="p">,</span> <span class="n">newPath</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">void</span> <span class="nf">DeleteRecording</span><span class="p">(</span><span class="kt">string</span> <span class="n">recordingSid</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="n">File</span><span class="p">.</span><span class="nf">Delete</span><span class="p">(</span><span class="nf">GetRecordingPathBySid</span><span class="p">(</span><span class="n">recordingSid</span><span class="p">));</span>
    <span class="k">private</span> <span class="kt">string</span> <span class="nf">GetRecordingPathBySid</span><span class="p">(</span><span class="kt">string</span> <span class="n">recordingSid</span><span class="p">)</span>
        <span class="p">=&gt;</span> <span class="n">Directory</span><span class="p">.</span><span class="nf">GetFiles</span><span class="p">(</span><span class="s">$"</span><span class="p">{</span><span class="n">_rootVoicemailPath</span><span class="p">}</span><span class="s">/"</span><span class="p">,</span> <span class="s">"*.mp3"</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">Single</span><span class="p">(</span><span class="n">s</span> <span class="p">=&gt;</span> <span class="n">s</span><span class="p">.</span><span class="nf">Contains</span><span class="p">(</span><span class="n">recordingSid</span><span class="p">));</span>
<span class="p">}</span>
</code></pre></div></div>

<p>If the user has pressed 1 to replay the message, you don’t have to do anything in your API other than return the same response. As long as the message is left at the top of the queue, it will be played to the caller.</p>

<p>If the user has pressed 2 to save the message, the controller calls the <code class="language-plaintext highlighter-rouge">SaveRecording</code> method of the <code class="language-plaintext highlighter-rouge">FileService</code>, which renames the file by replacing “New” with “Saved”. This way, the next time you call your voicemail service, this recording will be treated as an old recording.</p>

<p>If the user has pressed 3 to delete the message, the controller calls the <code class="language-plaintext highlighter-rouge">DeleteRecording</code> method of the <code class="language-plaintext highlighter-rouge">FileService</code>, which deletes the file from the file system.</p>

<p>If the caller has pressed any other key,  the action returns the TwiML to say that the key was invalid, replays the valid options, and listens for the next dialpad button to be pressed, so that they can correct their mistake.</p>

<h2 id="test-the-application">Test the application</h2>

<p>First, to test leaving a voicemail, remove your phone number from appsettings.json and run the application with the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet run
</code></pre></div></div>

<p>Call your Twilio phone number. You should be greeted with “Hello, please leave a message after the beep.”. Leave your message. After a few seconds, you should see a new MP3 file saved under wwwroot/Voicemails directory prefixed with “New_” indicating that it has not been played to the user before (at least not saved by the user, so it’s treated as a new message).</p>

<p>Update the appsettings.json by adding your number in the owner’s list and call your Twilio phone number again.</p>

<p>This time you should be greeted with a message telling you have 1 new message and no saved messages, followed by the recorded message. You can then take action and choose what to do with the messages.</p>

<h2 id="conclusion">Conclusion</h2>

<p>When determining the scope and business logic of this voicemail service, I used my own phone provider as a guide. I was able to add all the features that they provide, so by implementing this project you created yourself a voicemail service that actually matches the features of a real voicemail service. You can deploy this to a cloud provider and give your Twilio number to receive voicemails when you don’t want to distribute your own phone number.</p>

<p>Apart from that, you learned how to deal with call recordings and user input. If you’d like to keep learning, I recommend taking a look at these articles:</p>

<ul>
  <li>
    <p><a href="https://www.twilio.com/blog/build-a-chatgpt-sms-bot-with-the-openai-api-and-aspdotnet-core">Build a ChatGPT SMS bot with the OpenAI API and ASP.NET Core</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/build-a-soundboard-using-gcp-speech-to-text-twilio-voice-media-streams-and-aspdotnet-core">Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/automatic-instrumentation-of-containerized-dotnet-applications-with-opentelemetry">Automatic Instrumentation of Containerized .NET Applications With OpenTelemetry</a></p>
  </li>
</ul>
]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core]]></title>
    <link href="https://volkanpaksoy.com/archive/2026/08/05/Build-a-Soundboard-using-GCP-Speech-To-Text-Twilio-Voice-Media-Streams-and-ASPNET-Core/"/>
    <updated>2026-08-05T12:05:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2026/08/05/Build-a-Soundboard-using-GCP-Speech-To-Text-Twilio-Voice-Media-Streams-and-ASPNET-Core</id>
    <content type="html"><![CDATA[<blockquote>
  <p>This article was originally published on the <a href="https://www.twilio.com/en-us/blog/build-a-soundboard-using-gcp-speech-to-text-twilio-voice-media-streams-and-aspdotnet-core">Twilio Blog</a>.</p>
</blockquote>

<p><a href="https://www.twilio.com/media-streams">Twilio Media Streams</a> give programmers access to the raw audio of a phone call in real-time. This allows you to process the media and enhance your applications by running sentiment analysis, speech recognition, etc. In this tutorial, you will learn how to receive the raw audio via WebSockets, transcribe the call using Google Cloud’s Speech-to-Text service and play audio files based on the user’s commands.</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>You’ll need the following things in this tutorial:</p>

<ul>
  <li>
    <p>A free <a href="https://www.twilio.com/try-twilio">Twilio account</a></p>
  </li>
  <li>
    <p>A <a href="https://support.twilio.com/hc/en-us/articles/223135247-How-to-Search-for-and-Buy-a-Twilio-Phone-Number-from-Console">Twilio phone number</a></p>
  </li>
  <li>
    <p>An OS that supports .NET (Windows/macOS/Linux)</p>
  </li>
  <li>
    <p><a href="https://dotnet.microsoft.com/en-us/download/dotnet/7.0">.NET 7.0 SDK</a> (newer and older versions may work too)</p>
  </li>
  <li>
    <p>A code editor or IDE (Recommended: <a href="https://code.visualstudio.com/Download">Visual Studio Code</a> with <a href="https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp">the C# plugin</a>, <a href="https://visualstudio.microsoft.com/">Visual Studio</a>, or <a href="https://www.jetbrains.com/rider/">JetBrains Rider</a>)</p>
  </li>
  <li>
    <p><a href="https://ngrok.com/">ngrok</a> (A <a href="https://dashboard.ngrok.com/signup">free ngrok account</a> is sufficient for this tutorial)</p>
  </li>
  <li>
    <p>A free <a href="https://cloud.google.com/free">Google Cloud Platform (GCP) account</a></p>
  </li>
  <li>
    <p>​​<a href="https://git-scm.com/downloads">Git CLI</a></p>
  </li>
</ul>

<h2 id="set-up-gcp-speech-to-text">Set up GCP Speech-to-Text</h2>

<p>To use <a href="https://cloud.google.com/speech-to-text/">the Speech-to-Text API</a>, you must enable it in the Google Cloud console. If you have never used GCP, you can log in to your Google account and go to the <a href="https://cloud.google.com/free">free trial start page</a> and</p>

<p>click the Start free button.</p>

<p>You will be asked to enter your personal information through a 2-step process. Once you’ve completed the process, you should gain access to $300 free credits that will be valid for 90 days.</p>

<p>To start developing your application, you will need to create a project in Google Cloud. In my account, Google automatically created a new project called “My First Project”. If you don’t have this or would like to create a brand new one, go to Menu &gt; IAM &amp; Admin &gt; <a href="https://console.cloud.google.com/projectcreate">Create a Project</a>.</p>

<p>You should see the new project creation screen with a default name already chosen for you. You can change it to your liking:</p>

<p><img src="/images/vpblogimg/2026/08/Build-a-Soundboard-using-GCP-Speech-To-Text-Twilio-Voice-Media-Streams-and-ASPNET-Core/01.png" alt="Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 1" /></p>

<p>Click the Create button to finish project creation. To switch between the projects, you can click on the project name next to Google Cloud logo and browse your projects.</p>

<p><img src="/images/vpblogimg/2026/08/Build-a-Soundboard-using-GCP-Speech-To-Text-Twilio-Voice-Media-Streams-and-ASPNET-Core/02.png" alt="Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 2" /></p>

<p>The dialog also has a New Project button which you can use to create new projects.</p>

<p>Once you’ve created and selected your project, go to <a href="https://console.cloud.google.com/apis/library/speech.googleapis.com">the Cloud Speech-to-Text API product page</a> and click Enable.</p>

<p><img src="/images/vpblogimg/2026/08/Build-a-Soundboard-using-GCP-Speech-To-Text-Twilio-Voice-Media-Streams-and-ASPNET-Core/03.png" alt="Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 3" /></p>

<p>You should see a notification advising you to create credentials. Click Create Credentials.</p>

<p><img src="/images/vpblogimg/2026/08/Build-a-Soundboard-using-GCP-Speech-To-Text-Twilio-Voice-Media-Streams-and-ASPNET-Core/04.png" alt="Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 4" /></p>

<p>In the Which API you are using section, select Cloud Speech-to-Text API if it’s not already selected.</p>

<p><img src="/images/vpblogimg/2026/08/Build-a-Soundboard-using-GCP-Speech-To-Text-Twilio-Voice-Media-Streams-and-ASPNET-Core/05.png" alt="Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 5" /></p>

<p>In the What data will you be accessing section, select Application data.</p>

<p>Select No, I’m not using them as the answer to the Are you planning to use this API with Compute Engine… question and click Next.</p>

<p>On the Service account details page, give your service account a name such as transcribe-twilio-call.</p>

<p>The Service Account ID should be automatically populated based on the name you chose.</p>

<p>Click Create and Continue.</p>

<p>The rest of the settings are optional, so you can click Done and complete the process.</p>

<p>To use this service account, you will need credentials. On the left menu, click Credentials.</p>

<p>While still on the Cloud Speech-to-Text API page, switch to the Credentials tab.</p>

<p><img src="/images/vpblogimg/2026/08/Build-a-Soundboard-using-GCP-Speech-To-Text-Twilio-Voice-Media-Streams-and-ASPNET-Core/06.png" alt="Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 6" /></p>

<p>Scroll down to the Service Accounts and click your account.</p>

<p>Switch to the Keys section and click Add Key → Create new key.</p>

<p>Select JSON if not selected already, and click Create.</p>

<p><img src="/images/vpblogimg/2026/08/Build-a-Soundboard-using-GCP-Speech-To-Text-Twilio-Voice-Media-Streams-and-ASPNET-Core/07.png" alt="Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 7" /></p>

<p>This should start a download of your private key in a JSON file.</p>

<p>Copy this file to a safe location and set the <code class="language-plaintext highlighter-rouge">GOOGLE_APPLICATION_CREDENTIALS</code> environment variable by running the command appropriate to your system.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">GOOGLE_APPLICATION_CREDENTIALS</span><span class="o">={</span> PATH TO YOUR JSON FILE <span class="o">}</span>
</code></pre></div></div>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$</span><span class="nn">Env</span><span class="p">:</span><span class="nv">GOOGLE_APPLICATION_CREDENTIALS</span><span class="o">=</span><span class="p">{</span><span class="w"> </span><span class="n">PATH</span><span class="w"> </span><span class="nx">TO</span><span class="w"> </span><span class="nx">YOUR</span><span class="w"> </span><span class="nx">JSON</span><span class="w"> </span><span class="nx">FILE</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<pre><code class="language-cmd">set GOOGLE_APPLICATION_CREDENTIALS={ PATH TO YOUR JSON FILE }
</code></pre>

<h2 id="twilio-media-streams">Twilio Media Streams</h2>

<p>In a Twilio voice application, you can use <a href="https://www.twilio.com/docs/voice/twiml/stream">the Stream verb</a> to receive raw audio streams from a live phone call over WebSockets in near real-time.</p>

<h3 id="websockets">WebSockets</h3>

<p>A WebSocket is a protocol for bidirectional communication between a client (such as a web browser) and a server over a single, long-lived connection. WebSockets allow for real-time, two-way communication between the client and server and can be used for a variety of applications such as online gaming, chat applications, and data streaming. Unlike traditional HTTP connections, which are request-response based, WebSockets provide a full-duplex communication channel for continuous, real-time data exchange.</p>

<h3 id="stream-websocket-messages">Stream WebSocket Messages</h3>

<p>In the Twilio Stream WebSocket, each message is sent in a JSON string. There are different message types, and to identify the message type, first, you need to parse the JSON and check the value of the <code class="language-plaintext highlighter-rouge">event</code> field.</p>

<p>The possible values for the WebSocket messages coming from Twilio are:</p>

<ul>
  <li>
    <p><code class="language-plaintext highlighter-rouge">connected</code>: The first message sent once a WebSocket connection is established.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">start</code>: This message contains important metadata about the stream and is sent immediately after the <code class="language-plaintext highlighter-rouge">connected</code> message. It is only sent once at the start of the <code class="language-plaintext highlighter-rouge">Stream</code>.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">media</code>: This message type encapsulates the raw audio data.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">stop</code>: This message will be sent when the <code class="language-plaintext highlighter-rouge">Stream</code> is stopped or the call has ended.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">mark</code>: The mark event is sent only during bidirectional streaming using the <code class="language-plaintext highlighter-rouge">&lt;Connect&gt;</code> verb. It is used to track or label when media has completed.</p>
  </li>
</ul>

<p>The possible values for the WebSocket messages coming from Twilio are:</p>

<ul>
  <li>
    <p><code class="language-plaintext highlighter-rouge">media</code>: To send media back to Twilio, you must provide a similarly formatted media message. The payload must be encoded <code class="language-plaintext highlighter-rouge">audio/x-mulaw</code> with a sample rate of 8000 and base64 encoded. The audio can be of any size.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">mark</code>: Send a mark event message after sending a media event message to be notified when the audio that you have sent has been completed.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">clear</code>: Send the clear event message if you would like to interrupt the audio that has been sent various media event messages.</p>
  </li>
</ul>

<p>In the demo project, you will learn more about the other fields that are used in these messages.</p>

<h2 id="wave-file-format-analysis">WAVE File Format Analysis</h2>

<p>The telephony standard for audio is 8-bit PCM mono uLaw (MULAW) with a sampling rate of 8Khz. The payload of the media message should not contain the audio file type header bytes. So it’s essential to understand the WAV file header fields so that you can strip them off before sending the audio data to the user.</p>

<p>A standard WAV file header comprises the following fields:</p>

<p>Positions</p>

<p>Sample Value</p>

<p>Description</p>

<p>1 - 4</p>

<p>“RIFF”</p>

<p>Marks the file as a riff file. Characters are each 1 byte long.</p>

<p>5 - 8</p>

<p>File size (integer)</p>

<p>Size of the overall file - 8 bytes, in bytes (32-bit integer). Typically, you’d fill this in after creation.</p>

<p>9 -12</p>

<p>“WAVE”</p>

<p>File Type Header. For our purposes, it always equals “WAVE”.</p>

<p>13-16</p>

<p>“fmt “</p>

<p>Format chunk marker. Includes trailing null</p>

<p>17-20</p>

<p>16</p>

<p>Length of format data as listed above</p>

<p>21-22</p>

<p>1</p>

<p>Type of format (1 is PCM) - 2 byte integer</p>

<p>23-24</p>

<p>2</p>

<p>Number of Channels - 2 byte integer</p>

<p>25-28</p>

<p>44100</p>

<p>Sample Rate - 32 byte integer. Common values are 44100 (CD), 48000 (DAT). Sample Rate = Number of Samples per second, or Hertz.</p>

<p>29-32</p>

<p>176400</p>

<p>(Sample Rate * BitsPerSample * Channels) / 8.</p>

<p>33-34</p>

<p>4</p>

<p>(BitsPerSample * Channels) / 8.1 - 8 bit mono2 - 8 bit stereo/16 bit mono4 - 16 bit stereo</p>

<p>35-36</p>

<p>16</p>

<p>Bits per sample</p>

<p>37-40</p>

<p>“data”</p>

<p>“data” chunk header. Marks the beginning of the data section.</p>

<p>41-44</p>

<p>File size (data)</p>

<p>Size of the data section.</p>

<p>(Source: <a href="https://docs.fileformat.com/audio/wav/">https://docs.fileformat.com/audio/wav/</a>)</p>

<p>A WAVE file is a collection of a number of different types of chunks. The fmt chunk is required, and it contains parameters describing the waveform.</p>

<p>Now, open the <a href="https://github.com/Dev-Power/play-audio-to-a-phone-call-using-media-streams/raw/starter-project/audio/bird.wav">bird.wav</a> in a hex editor and review the file. Note that the file length is 54,084 bytes.</p>

<p><img src="/images/vpblogimg/2026/08/Build-a-Soundboard-using-GCP-Speech-To-Text-Twilio-Voice-Media-Streams-and-ASPNET-Core/08.png" alt="Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 8" /></p>

<p>Here you can see the fields:</p>

<p>Positions</p>

<p>Bytes</p>

<p>Value</p>

<p>Explanation</p>

<p>1 - 4</p>

<p>52 49 46 46</p>

<p>“RIFF”</p>

<p>As expected</p>

<p>5 - 8</p>

<p>00 00 D3 3C</p>

<p>54,076 (Little-endian) (File length -8)</p>

<p>As expected</p>

<p>9 - 12</p>

<p>57 41 56 45</p>

<p>“WAVE”</p>

<p>As expected</p>

<p>13 - 16</p>

<p>66 6D 74 20</p>

<p>“fmt” with trailing space</p>

<p>As expected</p>

<p>17- 20</p>

<p>12 00 00 00</p>

<p>Length of fmt chunk data: 18</p>

<p>As expected. <a href="https://www.mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html">Can be 16, 18 or 40</a></p>

<p>21 - 22</p>

<p>00 07</p>

<p>Type of format: Mulaw (7)</p>

<p><a href="https://www.mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html">As expected</a></p>

<p>23 - 24</p>

<p>00 01</p>

<p>Number of channels: 1</p>

<p>As expected</p>

<p>25 - 28</p>

<p>00 00 1F 40</p>

<p>Sample rate: 8000</p>

<p>As expected</p>

<p>29 - 32</p>

<p>00 00 1F 40</p>

<p>(Sample Rate * Bit per sample * Channels) / 8</p>

<p>(8000 * 8 * 1) / 8 = 8000</p>

<p>As expected</p>

<p>33 - 34</p>

<p>00 01</p>

<p>(BitsPerSample * Channels) / 8</p>

<p>(8 * 1) / 8 = 1</p>

<p>As expected</p>

<p>35 - 36</p>

<p>00 08</p>

<p>8 Bits per sample</p>

<p>As expected</p>

<p>37 - 38</p>

<p>00 00</p>

<p>Size of the extension: 0</p>

<p>Expected: Start of data. <a href="https://www.mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html">Microsoft Windows Media Player will not play non-PCM data (e.g. µ-law data) if the fmt chunk does not have the extension size field (cbSize) or a fact chunk is not present.</a></p>

<p>39 - 42</p>

<p>66 61 63 74</p>

<p>“fact”</p>

<p>Optional fact chunk</p>

<p>43 - 46</p>

<p>00 00 00 04</p>

<p>4 = Size of the fact chunk data</p>

<p>47 - 50</p>

<p>00 00 D3 0A</p>

<p>54,026 = chunk data. Equal to file length.</p>

<p><a href="https://www.recordingblogs.com/wiki/fact-chunk-of-a-wave-file">Fact chunk explanation</a></p>

<p>51 - 54</p>

<p>64 61 74 61</p>

<p>“data”</p>

<p>As expected, except it starts at 51 because of the fact chunk</p>

<p>55 - 58</p>

<p>00 00 D3 0A</p>

<p>Size of the data: 54,026</p>

<p>As expected</p>

<p>As you can see, the actual file header diverges slightly from the standard header description.</p>

<p>The takeaways from this analysis are:</p>

<ul>
  <li>
    <p>The audio data starts after the first 58 bytes. You will skip those bytes in the demo and only send the audio data to the caller.</p>
  </li>
  <li>
    <p>You may encounter different header lengths and subsequently may need to adjust the number of bytes to skip; otherwise, you may hear distorted audio on the phone.</p>
  </li>
</ul>

<p>Now that you understand the WAV format better, proceed to the next section to implement the project to play audio files to a phone call.</p>

<h2 id="sample-project-animal-soundboard">Sample Project: Animal Soundboard</h2>

<p>The project requires to have some audio files to function properly. The easiest way to set up the starter project is by cloning the <a href="https://github.com/Dev-Power/play-audio-to-a-phone-call-using-media-streams">sample GitHub repository</a>.</p>

<p>Open a terminal, change to the directory you want to download the project, and run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/Dev-Power/play-audio-to-a-phone-call-using-media-streams.git <span class="nt">--branch</span> starter-project
</code></pre></div></div>

<p>The project can be found in the src\PlayAudioUsingMediaStreams subfolder. Open the project in your IDE.</p>

<p>The starter project comes with 2 controllers: <code class="language-plaintext highlighter-rouge">IncomingCallController</code> and <code class="language-plaintext highlighter-rouge">AnimalSoundboardController</code>. <code class="language-plaintext highlighter-rouge">IncomingCallController</code> currently only plays back a simple message to test your setup. You will implement  <code class="language-plaintext highlighter-rouge">AnimalSoundboardController</code> as you go along.</p>

<p>It also comes with 4 WAV files that will be used in the project.</p>

<p>Open another terminal and run ngrok like this:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ngrok http http://localhost:5214 
</code></pre></div></div>

<p>For Twilio to know where to send webhook requests, you need to update the webhook settings on your Twilio phone number.</p>

<p>Go to the <a href="https://www.twilio.com/console">Twilio Console</a>. Select your account, and then click Phone Numbers → Manage → Active Numbers on the left pane. (If Phone Numbers isn’t on the left pane, click on Explore Products and then on Phone Numbers.)</p>

<p>Click on the phone number you want to use for your project and scroll down to the Voice section.</p>

<p>Under the A Call Comes In label, set the dropdown to Webhook, the text field next to it to the ngrok Forwarding URL suffixed with the /IncomingCall path, the next dropdown to HTTP POST, and click Save. It should look like this:</p>

<p><img src="/images/vpblogimg/2026/08/Build-a-Soundboard-using-GCP-Speech-To-Text-Twilio-Voice-Media-Streams-and-ASPNET-Core/09.png" alt="Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 9" /></p>

<p>Note that you have to use HTTPS as the protocol when setting the webhook URL.</p>

<p>In the terminal, run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet run
</code></pre></div></div>

<p>Call your Twilio number, and you should hear the message “If you can hear this, your setup works!” played back to you.</p>

<p>After you’ve confirmed you can receive calls in your application, update the code in the <code class="language-plaintext highlighter-rouge">Index</code> method of the <code class="language-plaintext highlighter-rouge">IncomingCallController</code> with the code below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">VoiceResponse</span><span class="p">();</span>
<span class="n">response</span><span class="p">.</span><span class="nf">Say</span><span class="p">(</span><span class="s">"Say animal names to hear their sounds."</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">connect</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">Connect</span><span class="p">();</span>
<span class="n">connect</span><span class="p">.</span><span class="nf">Stream</span><span class="p">(</span>
    <span class="n">name</span><span class="p">:</span> <span class="s">"Animal Soundboard"</span><span class="p">,</span> 
    <span class="n">url</span><span class="p">:</span> <span class="n">Url</span><span class="p">.</span><span class="nf">Action</span><span class="p">(</span>
        <span class="n">action</span><span class="p">:</span> <span class="s">"Get"</span><span class="p">,</span> 
        <span class="n">controller</span><span class="p">:</span> <span class="s">"AnimalSoundboard"</span><span class="p">,</span>
        <span class="n">values</span><span class="p">:</span> <span class="k">null</span><span class="p">,</span>
        <span class="n">protocol</span><span class="p">:</span> <span class="s">"wss"</span>
    <span class="p">)</span>
<span class="p">);</span>
<span class="n">response</span><span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="n">connect</span><span class="p">);</span>
<span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="nf">ToString</span><span class="p">());</span>
<span class="k">return</span> <span class="nf">TwiML</span><span class="p">(</span><span class="n">response</span><span class="p">);</span>
</code></pre></div></div>

<p>This update replaces the message and adds <a href="https://www.twilio.com/docs/voice/twiml/stream">Stream verb</a> to the output. It prints the response before sending it back, so you can see the TwiML you created, which looks like this:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?xml version="1.0" encoding="utf-8"?&gt;</span>
<span class="nt">&lt;Response&gt;</span>
  <span class="nt">&lt;Say&gt;</span>Say animal names to hear their sounds.<span class="nt">&lt;/Say&gt;</span>
  <span class="nt">&lt;Connect&gt;</span>
    <span class="nt">&lt;Stream</span> <span class="na">name=</span><span class="s">"Animal Soundboard"</span> <span class="na">url=</span><span class="s">"wss://{YOUR NGROK URL}/animalsoundboard"</span><span class="nt">&gt;&lt;/Stream&gt;</span>
  <span class="nt">&lt;/Connect&gt;</span>
<span class="nt">&lt;/Response&gt;</span>
</code></pre></div></div>

<p>In the demo, you will receive raw user audio and play animal sounds back depending on the commands you receive, so you have to maintain a synchronous bi-directional connection. This is why you use the <code class="language-plaintext highlighter-rouge">Connect</code> verb instead of the <code class="language-plaintext highlighter-rouge">Start</code> verb, which is asynchronous and immediately continues with the next TwiML instruction. You can <a href="https://www.twilio.com/docs/voice/twiml/stream">read more about TwiML stream verbs here</a>.</p>

<p>Now, it’s time to implement the web socket. The first version will just echo the user’s voice back. Update the <code class="language-plaintext highlighter-rouge">AnimalSoundboardController</code> with the code below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">System.Net.WebSockets</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Mvc</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Twilio.AspNet.Core</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">PlayAudioUsingMediaStreams.WebApi.Controllers</span><span class="p">;</span>
<span class="p">[</span><span class="n">ApiController</span><span class="p">]</span>
<span class="p">[</span><span class="nf">Route</span><span class="p">(</span><span class="s">"[controller]"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">AnimalSoundboardController</span> <span class="p">:</span> <span class="n">Controller</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">Get</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">HttpContext</span><span class="p">.</span><span class="n">WebSockets</span><span class="p">.</span><span class="n">IsWebSocketRequest</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">using</span> <span class="nn">var</span> <span class="n">webSocket</span> <span class="p">=</span> <span class="k">await</span> <span class="n">HttpContext</span><span class="p">.</span><span class="n">WebSockets</span><span class="p">.</span><span class="nf">AcceptWebSocketAsync</span><span class="p">();</span>
            <span class="k">await</span> <span class="nf">Soundboard</span><span class="p">(</span><span class="n">webSocket</span><span class="p">);</span>
        <span class="p">}</span>
        <span class="k">else</span>
        <span class="p">{</span>
            <span class="n">HttpContext</span><span class="p">.</span><span class="n">Response</span><span class="p">.</span><span class="n">StatusCode</span> <span class="p">=</span> <span class="n">StatusCodes</span><span class="p">.</span><span class="n">Status400BadRequest</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="k">private</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">Soundboard</span><span class="p">(</span><span class="n">WebSocket</span> <span class="n">webSocket</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">buffer</span> <span class="p">=</span> <span class="k">new</span> <span class="kt">byte</span><span class="p">[</span><span class="m">1024</span> <span class="p">*</span> <span class="m">4</span><span class="p">];</span>
        <span class="kt">var</span> <span class="n">receiveResult</span> <span class="p">=</span> <span class="k">await</span> <span class="n">webSocket</span><span class="p">.</span><span class="nf">ReceiveAsync</span><span class="p">(</span>
            <span class="k">new</span> <span class="n">ArraySegment</span><span class="p">&lt;</span><span class="kt">byte</span><span class="p">&gt;(</span><span class="n">buffer</span><span class="p">),</span> <span class="n">CancellationToken</span><span class="p">.</span><span class="n">None</span><span class="p">);</span>
        <span class="k">while</span> <span class="p">(!</span><span class="n">receiveResult</span><span class="p">.</span><span class="n">CloseStatus</span><span class="p">.</span><span class="n">HasValue</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">await</span> <span class="n">webSocket</span><span class="p">.</span><span class="nf">SendAsync</span><span class="p">(</span>
                <span class="k">new</span> <span class="n">ArraySegment</span><span class="p">&lt;</span><span class="kt">byte</span><span class="p">&gt;(</span><span class="n">buffer</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="n">receiveResult</span><span class="p">.</span><span class="n">Count</span><span class="p">),</span>
                <span class="n">receiveResult</span><span class="p">.</span><span class="n">MessageType</span><span class="p">,</span>
                <span class="n">receiveResult</span><span class="p">.</span><span class="n">EndOfMessage</span><span class="p">,</span>
                <span class="n">CancellationToken</span><span class="p">.</span><span class="n">None</span><span class="p">);</span>
            <span class="n">receiveResult</span> <span class="p">=</span> <span class="k">await</span> <span class="n">webSocket</span><span class="p">.</span><span class="nf">ReceiveAsync</span><span class="p">(</span>
                <span class="k">new</span> <span class="n">ArraySegment</span><span class="p">&lt;</span><span class="kt">byte</span><span class="p">&gt;(</span><span class="n">buffer</span><span class="p">),</span> <span class="n">CancellationToken</span><span class="p">.</span><span class="n">None</span><span class="p">);</span>
        <span class="p">}</span>
        <span class="k">await</span> <span class="n">webSocket</span><span class="p">.</span><span class="nf">CloseAsync</span><span class="p">(</span>
            <span class="n">receiveResult</span><span class="p">.</span><span class="n">CloseStatus</span><span class="p">.</span><span class="n">Value</span><span class="p">,</span>
            <span class="n">receiveResult</span><span class="p">.</span><span class="n">CloseStatusDescription</span><span class="p">,</span>
            <span class="n">CancellationToken</span><span class="p">.</span><span class="n">None</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Before you run the application, you have to modify the Program.cs and add WebSocket support as shown below:</p>

<p>```csharp hl_lines=”3”
app.MapControllers();
app.UseWebSockets();
app.Run();</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
Re-run the application and call your Twilio phone number again. You should hear yourself on the phone as you speak.

This version of the code reads the streaming data from the web socket, and as long as the connection is open, it sends the same data back to the user.

This is how you can access the raw audio of a phone call. This primitive version does not look inside the messages. What you receive over the web socket is a JSON message. 

In the next version, you will parse the messages as well. Before that, you'll need some supporting services. 

To model the sounds, create a new file called Sound.cs and update its contents like this:

```csharp
namespace PlayAudioUsingMediaStreams.WebApi;
public class Sound
{
    public string Name { get; set; }
    public List&lt;string&gt; Keywords { get; set; }
    public string AudioDataAsBase64 { get; set; }
}
</code></pre></div></div>

<p>Every sound has a name, a list of keywords, and the audio data. In this project, you will use animal names as keywords, but the application can be used for any group of sounds.</p>

<p>Create a new folder called Services and a new file under it called SoundService.cs. Update the code as below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">PlayAudioUsingMediaStreams.WebApi.Services</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">SoundService</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">const</span> <span class="kt">string</span> <span class="n">AudioRoot</span> <span class="p">=</span> <span class="s">"../../audio"</span><span class="p">;</span>
    <span class="k">private</span> <span class="k">const</span> <span class="kt">int</span> <span class="n">WavHeaderBytesToSkip</span> <span class="p">=</span> <span class="m">58</span><span class="p">;</span>
    <span class="k">private</span> <span class="n">List</span><span class="p">&lt;</span><span class="n">Sound</span><span class="p">&gt;</span> <span class="n">_sounds</span> <span class="p">=</span> <span class="k">new</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="k">new</span><span class="p">()</span> <span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="s">"dog"</span><span class="p">,</span> <span class="n">Keywords</span> <span class="p">=</span> <span class="k">new</span> <span class="n">List</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="p">{</span> <span class="s">"dog"</span><span class="p">,</span> <span class="s">"canine"</span><span class="p">,</span> <span class="s">"pooch"</span><span class="p">,</span> <span class="s">"hound"</span> <span class="p">}</span> <span class="p">},</span>
        <span class="k">new</span><span class="p">()</span> <span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="s">"cat"</span><span class="p">,</span> <span class="n">Keywords</span> <span class="p">=</span> <span class="k">new</span> <span class="n">List</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="p">{</span> <span class="s">"cat"</span><span class="p">,</span> <span class="s">"kitty"</span><span class="p">,</span> <span class="s">"kitten"</span> <span class="p">}</span> <span class="p">},</span>
        <span class="k">new</span><span class="p">()</span> <span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="s">"bird"</span><span class="p">,</span> <span class="n">Keywords</span> <span class="p">=</span> <span class="k">new</span> <span class="n">List</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="p">{</span> <span class="s">"bird"</span> <span class="p">}</span> <span class="p">},</span>
        <span class="k">new</span><span class="p">()</span> <span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="s">"elephant"</span><span class="p">,</span> <span class="n">Keywords</span> <span class="p">=</span> <span class="k">new</span> <span class="n">List</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="p">{</span> <span class="s">"elephant"</span> <span class="p">}</span> <span class="p">},</span>
    <span class="p">};</span>
    <span class="k">public</span> <span class="nf">SoundService</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="c1">// Load all files into memory once to avoid constant disk access</span>
        <span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">sound</span> <span class="k">in</span> <span class="n">_sounds</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="kt">var</span> <span class="n">audioFilePath</span> <span class="p">=</span> <span class="s">$"</span><span class="p">{</span><span class="n">AudioRoot</span><span class="p">}</span><span class="s">/</span><span class="p">{</span><span class="n">sound</span><span class="p">.</span><span class="n">Name</span><span class="p">}</span><span class="s">.wav"</span><span class="p">;</span>
            <span class="kt">var</span> <span class="n">rawAudioData</span> <span class="p">=</span> <span class="n">File</span><span class="p">.</span><span class="nf">ReadAllBytes</span><span class="p">(</span><span class="n">audioFilePath</span><span class="p">);</span>
            <span class="c1">// Skip the header bytes while copying</span>
            <span class="kt">var</span> <span class="n">tempAudioData</span> <span class="p">=</span> <span class="k">new</span> <span class="kt">byte</span><span class="p">[</span><span class="n">rawAudioData</span><span class="p">.</span><span class="n">Length</span> <span class="p">-</span> <span class="n">WavHeaderBytesToSkip</span><span class="p">];</span>
            <span class="n">Array</span><span class="p">.</span><span class="nf">Copy</span><span class="p">(</span><span class="n">rawAudioData</span><span class="p">,</span> <span class="n">WavHeaderBytesToSkip</span><span class="p">,</span> <span class="n">tempAudioData</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="n">tempAudioData</span><span class="p">.</span><span class="n">Length</span><span class="p">);</span>
            <span class="n">sound</span><span class="p">.</span><span class="n">AudioDataAsBase64</span> <span class="p">=</span> <span class="n">Convert</span><span class="p">.</span><span class="nf">ToBase64String</span><span class="p">(</span><span class="n">tempAudioData</span><span class="p">);</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="kt">bool</span> <span class="nf">TryFindSoundByKeyword</span><span class="p">(</span><span class="kt">string</span> <span class="n">keyword</span><span class="p">,</span> <span class="k">out</span> <span class="n">Sound</span> <span class="n">sound</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">sound</span> <span class="p">=</span> <span class="n">_sounds</span><span class="p">.</span><span class="nf">FirstOrDefault</span><span class="p">(</span><span class="n">s</span> <span class="p">=&gt;</span> <span class="n">s</span><span class="p">.</span><span class="n">Keywords</span><span class="p">.</span><span class="nf">Contains</span><span class="p">(</span><span class="n">keyword</span><span class="p">));</span>
        <span class="k">return</span> <span class="n">sound</span> <span class="p">!=</span> <span class="k">null</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>At construction, the service initializes all the sound objects. It loads the audio data into memory, so they can be played in rapid succession without having to access the files from disk repeatedly.</p>

<p>Also, it handles skipping the wav header bytes, as discussed in the previous section.</p>

<p>You also need to identify the keywords that the user is uttering. To achieve this, you’ll need to use Google Speech-to-Text service. Install the SDK by running the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package Google.Cloud.Speech.V1
</code></pre></div></div>

<p>Under the Services folder, create a new file called SpeechRecognitionService.cs with the following contents:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Google.Api.Gax.Grpc</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Google.Cloud.Speech.V1</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Google.Protobuf</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">PlayAudioUsingMediaStreams.WebApi.Services</span><span class="p">;</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">SpeechRecognitionService</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="n">StreamingRecognitionConfig</span> <span class="n">_streamingConfig</span> <span class="p">=</span> <span class="k">new</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="n">Config</span> <span class="p">=</span> <span class="k">new</span> <span class="n">RecognitionConfig</span>
        <span class="p">{</span>
            <span class="n">Encoding</span> <span class="p">=</span> <span class="n">RecognitionConfig</span><span class="p">.</span><span class="n">Types</span><span class="p">.</span><span class="n">AudioEncoding</span><span class="p">.</span><span class="n">Mulaw</span><span class="p">,</span>
            <span class="n">SampleRateHertz</span> <span class="p">=</span> <span class="m">8000</span><span class="p">,</span>
            <span class="n">LanguageCode</span> <span class="p">=</span> <span class="s">"en-US"</span><span class="p">,</span>
            <span class="n">EnableWordConfidence</span> <span class="p">=</span> <span class="k">true</span><span class="p">,</span>
            <span class="n">UseEnhanced</span> <span class="p">=</span> <span class="k">true</span>
        <span class="p">},</span>
        <span class="n">InterimResults</span> <span class="p">=</span> <span class="k">true</span>
    <span class="p">};</span>
    <span class="k">private</span> <span class="n">SpeechClient</span> <span class="n">_speechClient</span><span class="p">;</span>
    <span class="k">private</span> <span class="n">SpeechClient</span><span class="p">.</span><span class="n">StreamingRecognizeStream</span> <span class="n">_streamingRecognizeStream</span><span class="p">;</span>
    <span class="k">public</span> <span class="nf">SpeechRecognitionService</span><span class="p">(</span><span class="n">SpeechClient</span> <span class="n">speechClient</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_speechClient</span> <span class="p">=</span> <span class="n">speechClient</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">AsyncResponseStream</span><span class="p">&lt;</span><span class="n">StreamingRecognizeResponse</span><span class="p">&gt;&gt;</span> <span class="nf">InitStream</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="n">_streamingRecognizeStream</span> <span class="p">=</span> <span class="n">_speechClient</span><span class="p">.</span><span class="nf">StreamingRecognize</span><span class="p">();</span>
        <span class="k">await</span> <span class="n">_streamingRecognizeStream</span><span class="p">.</span><span class="nf">WriteAsync</span><span class="p">(</span><span class="k">new</span> <span class="n">StreamingRecognizeRequest</span>
        <span class="p">{</span>
            <span class="n">StreamingConfig</span> <span class="p">=</span> <span class="n">_streamingConfig</span><span class="p">,</span>
        <span class="p">});</span>
        <span class="k">return</span> <span class="n">_streamingRecognizeStream</span><span class="p">.</span><span class="nf">GetResponseStream</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">SendAudio</span><span class="p">(</span><span class="kt">string</span> <span class="n">payload</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">await</span> <span class="n">_streamingRecognizeStream</span><span class="p">.</span><span class="nf">WriteAsync</span><span class="p">(</span><span class="k">new</span> <span class="n">StreamingRecognizeRequest</span>
        <span class="p">{</span>
            <span class="n">AudioContent</span> <span class="p">=</span> <span class="n">ByteString</span><span class="p">.</span><span class="nf">FromBase64</span><span class="p">(</span><span class="n">payload</span><span class="p">)</span>
        <span class="p">});</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This service is responsible for initializing the Google speech client. When you first create the stream, you write the recognition configuration as shown in the <code class="language-plaintext highlighter-rouge">InitStream</code> method. This returns the response stream; from that point on, you only write the audio data to the stream via the <code class="language-plaintext highlighter-rouge">SendAudio</code> method.</p>

<p>To be able to use these services with dependency injection, add them to the <a href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-7.0">IoC container</a>:</p>

<p>```csharp hl_lines=”3 4 5”
builder.Services.AddSwaggerGen();
builder.Services.AddSpeechClient();
builder.Services.AddTransient<SoundService>();
builder.Services.AddTransient<SpeechRecognitionService>();
var app = builder.Build();</SpeechRecognitionService></SoundService></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
Make sure to add the using statement to the top of the file as well:

```csharp
using PlayAudioUsingMediaStreams.WebApi.Services;
</code></pre></div></div>

<p>Finally, update the <code class="language-plaintext highlighter-rouge">AnimalSoundboardController</code> as shown below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">System.Net.WebSockets</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">System.Text</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">System.Text.Json</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Google.Api.Gax.Grpc</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Google.Cloud.Speech.V1</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Mvc</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">PlayAudioUsingMediaStreams.WebApi.Services</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">PlayAudioUsingMediaStreams.WebApi.Controllers</span><span class="p">;</span>
<span class="p">[</span><span class="n">ApiController</span><span class="p">]</span>
<span class="p">[</span><span class="nf">Route</span><span class="p">(</span><span class="s">"[controller]"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">AnimalSoundboardController</span> <span class="p">:</span> <span class="n">Controller</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">SoundService</span> <span class="n">_soundService</span><span class="p">;</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">SpeechRecognitionService</span> <span class="n">_speechRecognitionService</span><span class="p">;</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">IHostApplicationLifetime</span> <span class="n">_applicationLifetime</span><span class="p">;</span>
    <span class="k">public</span> <span class="nf">AnimalSoundboardController</span><span class="p">(</span>
        <span class="n">SoundService</span> <span class="n">soundService</span><span class="p">,</span>
        <span class="n">SpeechRecognitionService</span> <span class="n">speechRecognitionService</span><span class="p">,</span>
        <span class="n">IHostApplicationLifetime</span> <span class="n">applicationLifetime</span>
    <span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_soundService</span> <span class="p">=</span> <span class="n">soundService</span><span class="p">;</span>
        <span class="n">_speechRecognitionService</span> <span class="p">=</span> <span class="n">speechRecognitionService</span><span class="p">;</span>
        <span class="n">_applicationLifetime</span> <span class="p">=</span> <span class="n">applicationLifetime</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="p">[</span><span class="n">HttpGet</span><span class="p">]</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">Get</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">HttpContext</span><span class="p">.</span><span class="n">WebSockets</span><span class="p">.</span><span class="n">IsWebSocketRequest</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">using</span> <span class="nn">var</span> <span class="n">webSocket</span> <span class="p">=</span> <span class="k">await</span> <span class="n">HttpContext</span><span class="p">.</span><span class="n">WebSockets</span><span class="p">.</span><span class="nf">AcceptWebSocketAsync</span><span class="p">();</span>
            <span class="k">await</span> <span class="nf">Soundboard</span><span class="p">(</span><span class="n">webSocket</span><span class="p">);</span>
        <span class="p">}</span>
        <span class="k">else</span>
        <span class="p">{</span>
            <span class="n">HttpContext</span><span class="p">.</span><span class="n">Response</span><span class="p">.</span><span class="n">StatusCode</span> <span class="p">=</span> <span class="n">StatusCodes</span><span class="p">.</span><span class="n">Status400BadRequest</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="k">private</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">Soundboard</span><span class="p">(</span><span class="n">WebSocket</span> <span class="n">webSocket</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">string</span> <span class="n">streamSid</span> <span class="p">=</span> <span class="k">null</span><span class="p">;</span>
        <span class="kt">var</span> <span class="n">buffer</span> <span class="p">=</span> <span class="k">new</span> <span class="kt">byte</span><span class="p">[</span><span class="m">1024</span> <span class="p">*</span> <span class="m">4</span><span class="p">];</span>
        <span class="kt">var</span> <span class="n">receiveResult</span> <span class="p">=</span> <span class="k">await</span> <span class="n">webSocket</span><span class="p">.</span><span class="nf">ReceiveAsync</span><span class="p">(</span><span class="k">new</span> <span class="n">ArraySegment</span><span class="p">&lt;</span><span class="kt">byte</span><span class="p">&gt;(</span><span class="n">buffer</span><span class="p">),</span> <span class="n">CancellationToken</span><span class="p">.</span><span class="n">None</span><span class="p">);</span>
        <span class="k">await</span> <span class="k">using</span> <span class="nn">var</span> <span class="n">speechRecognitionStream</span> <span class="p">=</span> <span class="k">await</span> <span class="n">_speechRecognitionService</span><span class="p">.</span><span class="nf">InitStream</span><span class="p">();</span>
        <span class="k">while</span> <span class="p">(!</span><span class="n">receiveResult</span><span class="p">.</span><span class="n">CloseStatus</span><span class="p">.</span><span class="n">HasValue</span> <span class="p">&amp;&amp;</span>
               <span class="p">!</span><span class="n">_applicationLifetime</span><span class="p">.</span><span class="n">ApplicationStopping</span><span class="p">.</span><span class="n">IsCancellationRequested</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">using</span> <span class="nn">var</span> <span class="n">jsonDocument</span> <span class="p">=</span> <span class="n">JsonDocument</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="n">Encoding</span><span class="p">.</span><span class="n">UTF8</span><span class="p">.</span><span class="nf">GetString</span><span class="p">(</span><span class="n">buffer</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="n">receiveResult</span><span class="p">.</span><span class="n">Count</span><span class="p">));</span>
            <span class="kt">var</span> <span class="n">eventMessage</span> <span class="p">=</span> <span class="n">jsonDocument</span><span class="p">.</span><span class="n">RootElement</span><span class="p">.</span><span class="nf">GetProperty</span><span class="p">(</span><span class="s">"event"</span><span class="p">).</span><span class="nf">GetString</span><span class="p">();</span>
            <span class="k">switch</span> <span class="p">(</span><span class="n">eventMessage</span><span class="p">)</span>
            <span class="p">{</span>
                <span class="k">case</span> <span class="s">"connected"</span><span class="p">:</span>
                    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">"Event: connected"</span><span class="p">);</span>
                    <span class="k">break</span><span class="p">;</span>
                <span class="k">case</span> <span class="s">"start"</span><span class="p">:</span>
                    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">"Event: start"</span><span class="p">);</span>
                    <span class="n">streamSid</span> <span class="p">=</span> <span class="n">jsonDocument</span><span class="p">.</span><span class="n">RootElement</span><span class="p">.</span><span class="nf">GetProperty</span><span class="p">(</span><span class="s">"streamSid"</span><span class="p">).</span><span class="nf">GetString</span><span class="p">();</span>
                    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"StreamId: </span><span class="p">{</span><span class="n">streamSid</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
                    <span class="c1">// Do not await task, leave this task running in the background for the duration of the websocket connection</span>
                    <span class="kt">var</span> <span class="n">_</span> <span class="p">=</span> <span class="nf">ListenForSpeechRecognition</span><span class="p">(</span><span class="n">webSocket</span><span class="p">,</span> <span class="n">streamSid</span><span class="p">,</span> <span class="n">speechRecognitionStream</span><span class="p">)</span>
                        <span class="p">.</span><span class="nf">ConfigureAwait</span><span class="p">(</span><span class="k">false</span><span class="p">);</span>
                    <span class="k">break</span><span class="p">;</span>
                <span class="k">case</span> <span class="s">"media"</span><span class="p">:</span>
                    <span class="kt">var</span> <span class="n">payload</span> <span class="p">=</span> <span class="n">jsonDocument</span><span class="p">.</span><span class="n">RootElement</span><span class="p">.</span><span class="nf">GetProperty</span><span class="p">(</span><span class="s">"media"</span><span class="p">).</span><span class="nf">GetProperty</span><span class="p">(</span><span class="s">"payload"</span><span class="p">).</span><span class="nf">GetString</span><span class="p">();</span>
                    <span class="k">await</span> <span class="n">_speechRecognitionService</span><span class="p">.</span><span class="nf">SendAudio</span><span class="p">(</span><span class="n">payload</span><span class="p">);</span>
                    <span class="k">break</span><span class="p">;</span>
                <span class="k">case</span> <span class="s">"stop"</span><span class="p">:</span>
                    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">"Event: stop"</span><span class="p">);</span>
                    <span class="k">break</span><span class="p">;</span>
            <span class="p">}</span>
            <span class="n">receiveResult</span> <span class="p">=</span> <span class="k">await</span> <span class="n">webSocket</span><span class="p">.</span><span class="nf">ReceiveAsync</span><span class="p">(</span><span class="k">new</span> <span class="n">ArraySegment</span><span class="p">&lt;</span><span class="kt">byte</span><span class="p">&gt;(</span><span class="n">buffer</span><span class="p">),</span> <span class="n">CancellationToken</span><span class="p">.</span><span class="n">None</span><span class="p">);</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">receiveResult</span><span class="p">.</span><span class="n">CloseStatus</span><span class="p">.</span><span class="n">HasValue</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">await</span> <span class="n">webSocket</span><span class="p">.</span><span class="nf">CloseAsync</span><span class="p">(</span>
                <span class="n">receiveResult</span><span class="p">.</span><span class="n">CloseStatus</span><span class="p">.</span><span class="n">Value</span><span class="p">,</span>
                <span class="n">receiveResult</span><span class="p">.</span><span class="n">CloseStatusDescription</span><span class="p">,</span>
                <span class="n">CancellationToken</span><span class="p">.</span><span class="n">None</span><span class="p">);</span>
        <span class="p">}</span>
        <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">_applicationLifetime</span><span class="p">.</span><span class="n">ApplicationStopping</span><span class="p">.</span><span class="n">IsCancellationRequested</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">await</span> <span class="n">webSocket</span><span class="p">.</span><span class="nf">CloseAsync</span><span class="p">(</span>
                <span class="n">WebSocketCloseStatus</span><span class="p">.</span><span class="n">EndpointUnavailable</span><span class="p">,</span>
                <span class="s">"Server shutting down"</span><span class="p">,</span>
                <span class="n">CancellationToken</span><span class="p">.</span><span class="n">None</span><span class="p">);</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="k">private</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">ListenForSpeechRecognition</span><span class="p">(</span>
        <span class="n">WebSocket</span> <span class="n">webSocket</span><span class="p">,</span>
        <span class="kt">string</span> <span class="n">streamSid</span><span class="p">,</span>
        <span class="n">AsyncResponseStream</span><span class="p">&lt;</span><span class="n">StreamingRecognizeResponse</span><span class="p">&gt;</span> <span class="n">speechRecognitionStream</span>
    <span class="p">)</span>
    <span class="p">{</span>
        <span class="k">while</span> <span class="p">(</span><span class="k">await</span> <span class="n">speechRecognitionStream</span><span class="p">.</span><span class="nf">MoveNextAsync</span><span class="p">())</span>
        <span class="p">{</span>
            <span class="kt">var</span> <span class="n">word</span> <span class="p">=</span> <span class="n">speechRecognitionStream</span><span class="p">.</span><span class="n">Current</span><span class="p">?.</span><span class="n">Results</span><span class="p">.</span><span class="nf">FirstOrDefault</span><span class="p">()</span>
                <span class="p">?.</span><span class="n">Alternatives</span><span class="p">.</span><span class="nf">FirstOrDefault</span><span class="p">()</span>
                <span class="p">?.</span><span class="n">Words</span><span class="p">.</span><span class="nf">FirstOrDefault</span><span class="p">();</span>
            <span class="k">if</span> <span class="p">(</span><span class="n">word</span> <span class="p">==</span> <span class="k">null</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>
            <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"Word: [</span><span class="p">{</span><span class="n">word</span><span class="p">.</span><span class="n">Word</span><span class="p">}</span><span class="s">]. Confidence: </span><span class="p">{</span><span class="n">word</span><span class="p">.</span><span class="n">Confidence</span><span class="p">:</span><span class="n">N2</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
            <span class="k">if</span> <span class="p">(</span><span class="n">word</span><span class="p">.</span><span class="n">Confidence</span> <span class="p">&lt;</span> <span class="m">0.5</span><span class="p">)</span>
            <span class="p">{</span>
                <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"Low confidence. Skipping the word [</span><span class="p">{</span><span class="n">word</span><span class="p">.</span><span class="n">Word</span><span class="p">}</span><span class="s">]"</span><span class="p">);</span>
                <span class="k">continue</span><span class="p">;</span>
            <span class="p">}</span>
            <span class="kt">var</span> <span class="n">utterance</span> <span class="p">=</span> <span class="n">word</span><span class="p">.</span><span class="n">Word</span><span class="p">.</span><span class="nf">Trim</span><span class="p">().</span><span class="nf">ToLower</span><span class="p">();</span>
            <span class="k">if</span> <span class="p">(!</span><span class="n">_soundService</span><span class="p">.</span><span class="nf">TryFindSoundByKeyword</span><span class="p">(</span><span class="n">utterance</span><span class="p">,</span> <span class="k">out</span> <span class="kt">var</span> <span class="n">soundToPlay</span><span class="p">))</span>
            <span class="p">{</span>
                <span class="k">continue</span><span class="p">;</span>
            <span class="p">}</span>
            <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"Animal detected: </span><span class="p">{</span><span class="n">soundToPlay</span><span class="p">.</span><span class="n">Name</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
            <span class="kt">var</span> <span class="n">mediaMessage</span> <span class="p">=</span> <span class="k">new</span>
            <span class="p">{</span>
                <span class="n">streamSid</span><span class="p">,</span>
                <span class="n">@event</span> <span class="p">=</span> <span class="s">"media"</span><span class="p">,</span>
                <span class="n">media</span> <span class="p">=</span> <span class="k">new</span>
                <span class="p">{</span>
                    <span class="n">payload</span> <span class="p">=</span> <span class="n">soundToPlay</span><span class="p">.</span><span class="n">AudioDataAsBase64</span>
                <span class="p">}</span>
            <span class="p">};</span>
            <span class="kt">var</span> <span class="n">rawJson</span> <span class="p">=</span> <span class="n">JsonSerializer</span><span class="p">.</span><span class="nf">Serialize</span><span class="p">(</span><span class="n">mediaMessage</span><span class="p">);</span>
            <span class="kt">var</span> <span class="n">responseBuffer</span> <span class="p">=</span> <span class="n">Encoding</span><span class="p">.</span><span class="n">UTF8</span><span class="p">.</span><span class="nf">GetBytes</span><span class="p">(</span><span class="n">rawJson</span><span class="p">);</span>
            <span class="k">await</span> <span class="n">webSocket</span><span class="p">.</span><span class="nf">SendAsync</span><span class="p">(</span>
                <span class="k">new</span> <span class="n">ArraySegment</span><span class="p">&lt;</span><span class="kt">byte</span><span class="p">&gt;(</span><span class="n">responseBuffer</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="n">responseBuffer</span><span class="p">.</span><span class="n">Length</span><span class="p">),</span>
                <span class="n">WebSocketMessageType</span><span class="p">.</span><span class="n">Text</span><span class="p">,</span>
                <span class="k">true</span><span class="p">,</span>
                <span class="n">CancellationToken</span><span class="p">.</span><span class="n">None</span><span class="p">);</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>As mentioned before, now you’re parsing the JSON message:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">jsonDocument</span> <span class="p">=</span> <span class="n">JsonDocument</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="n">Encoding</span><span class="p">.</span><span class="n">UTF8</span><span class="p">.</span><span class="nf">GetString</span><span class="p">(</span><span class="n">buffer</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="n">receiveResult</span><span class="p">.</span><span class="n">Count</span><span class="p">))</span>
</code></pre></div></div>

<p>First action is to determine the message type. You achieve this by parsing the <code class="language-plaintext highlighter-rouge">event</code> property:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">string</span> <span class="n">eventMessage</span> <span class="p">=</span> <span class="n">jsonDocument</span><span class="p">.</span><span class="n">RootElement</span><span class="p">.</span><span class="nf">GetProperty</span><span class="p">(</span><span class="s">"event"</span><span class="p">).</span><span class="nf">GetString</span><span class="p">();</span>
</code></pre></div></div>

<p>As you saw at the beginning of the article, there are different types of events. In the application, you’re interested in 3 of them:</p>

<ul>
  <li>
    <p><code class="language-plaintext highlighter-rouge">connected</code>: This is where you initialize your Google speech client and the stream.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">start</code>: You receive the unique stream identifier in this message. This id must be stored to be able to send audio back to the caller.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">media</code>: Whenever you receive a media message, you parse the payload and send it to Google for speech recognition.</p>
  </li>
</ul>

<p>The final important update is to use both sound and speech recognition services to identify if a keyword was uttered by the user. If this happens, you prepare a new media message, convert it to JSON and send it to the user.</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">mediaMessage</span> <span class="p">=</span> <span class="k">new</span>
<span class="p">{</span>
    <span class="n">streamSid</span><span class="p">,</span> 
    <span class="n">@event</span> <span class="p">=</span> <span class="s">"media"</span><span class="p">,</span> 
    <span class="n">media</span> <span class="p">=</span> <span class="k">new</span>
    <span class="p">{</span>
        <span class="n">payload</span> <span class="p">=</span> <span class="n">soundToPlay</span><span class="p">.</span><span class="n">AudioDataAsBase64</span>
    <span class="p">}</span>
<span class="p">};</span>
</code></pre></div></div>

<p>To test the final version, rerun your application and call your phone.</p>

<p>Speak some of the keywords, and you should hear the corresponding animals’ sounds:</p>

<p><img src="/images/vpblogimg/2026/08/Build-a-Soundboard-using-GCP-Speech-To-Text-Twilio-Voice-Media-Streams-and-ASPNET-Core/10.png" alt="Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 10" /></p>

<h2 id="how-to-add-more-audio">How to Add More Audio</h2>

<p>If you enjoyed this little project and would like to add more sounds, here’s how I created the stock sounds:</p>

<p>Go to <a href="https://sound-effects.bbcrewind.co.uk/">BBC Sound Effects</a> website.</p>

<p>Search for the animal you’re looking for, click the download button, and select wav as the file format.</p>

<p><img src="/images/vpblogimg/2026/08/Build-a-Soundboard-using-GCP-Speech-To-Text-Twilio-Voice-Media-Streams-and-ASPNET-Core/11.png" alt="Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 11" /></p>

<p>Once you’ve downloaded the file, go to <a href="https://g711.org/">G711 File Converter</a>.</p>

<p>Locate your file by clicking Browse.</p>

<p>Select u-Law WAV as the output format and click Submit.</p>

<p>Click on the link of the converted file to download it.</p>

<p>Most audio files are too long to be able to play one after another quickly. I use <a href="https://www.audacityteam.org/download/">Audacity</a> to open the files and copy the part I’m interested in.</p>

<p><img src="/images/vpblogimg/2026/08/Build-a-Soundboard-using-GCP-Speech-To-Text-Twilio-Voice-Media-Streams-and-ASPNET-Core/12.png" alt="Build a Soundboard using GCP Speech-To-Text, Twilio Voice Media Streams, and ASP.NET Core - image 12" /></p>

<p>Once you’ve selected the portion you want, click File → Export → Export Selected Audio to save it as a separate file.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this tutorial, you learned how WebSockets work and how to use them in a voice application to establish a 2-way audio connection to the caller. You also learned more about media streams and the audio format standard for telephony. You used all this knowledge to implement a project to play audio files to the user based on their commands. This project shows you have the ability to access raw audio and partial transcriptions. Now you can use this to implement your own projects.</p>

<p>If you’d like to keep learning, I recommend taking a look at these articles:</p>

<ul>
  <li>
    <p><a href="https://www.twilio.com/blog/how-to-send-sms-in-30-seconds-with-fsharp">How to Send SMS in 30 Seconds with F#</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/generate-images-with-dall-e-2-and-twilio-sms-using-aspnet-core">Generate images with DALL·E 2 and Twilio SMS using ASP.NET Core</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/respond-to-sms-and-phone-calls-using-fastendpoints-and-twilio">Respond to SMS and Phone Calls using FastEndpoints and Twilio</a></p>
  </li>
</ul>
]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[Send Emails with C#, Handlebars Templating, and Dynamic Email Templates]]></title>
    <link href="https://volkanpaksoy.com/archive/2026/08/05/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/"/>
    <updated>2026-08-05T12:00:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2026/08/05/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates</id>
    <content type="html"><![CDATA[<blockquote>
  <p>This article was originally published on the <a href="https://www.twilio.com/en-us/blog/developers/community/send-emails-with-csharp-handlebars-templating-and-dynamic-email-templates">Twilio Blog</a>.</p>
</blockquote>

<p>Email communication is an essential aspect of most businesses. In this post, you will look into the basics of sending emails with the Twilio SendGrid Email API and sending templated emails with the Handlebars templating language. Finally, you will finish by putting it together in a sample project that sends emails based on a template.</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>You’ll need the following things for this tutorial:</p>

<ul>
  <li>
    <p>A free Twilio SendGrid account. <a href="https://signup.sendgrid.com/">Sign up for a SendGrid account here</a> to send up to 100 emails per day completely free of charge</p>
  </li>
  <li>
    <p>An OS that supports .NET (Windows/macOS/Linux)</p>
  </li>
  <li>
    <p><a href="https://dotnet.microsoft.com/en-us/download/dotnet/6.0">.NET 6.0 SDK (newer and older versions may work too)</a></p>
  </li>
  <li>
    <p>A code editor or IDE (Recommended: <a href="https://code.visualstudio.com/Download">Visual Studio Code</a> with <a href="https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp">the C# plugin</a>, <a href="https://visualstudio.microsoft.com/">Visual Studio</a>, or <a href="https://www.jetbrains.com/rider/">JetBrains Rider</a>)</p>
  </li>
</ul>

<h2 id="set-up-sendgrid">Set Up SendGrid</h2>

<h3 id="api-key">API Key</h3>

<p>First things first: To use the SendGrid API, you need an API key. Create one by heading over to the <a href="https://app.sendgrid.com/">SendGrid dashboard</a> and clicking on Settings → API Keys on the left menu and clicking the “Create API Key” button in the top-right corner:</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/01.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 1" /></p>

<p>Next, give your key a name and select the permissions. You can choose Restricted Access to pick individual permissions.</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/02.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 2" /></p>

<p>For brevity, in this tutorial, select Full Access. After selecting Full Access, click “Create &amp; View” to finish the key creation process.</p>

<p>The final step is crucial: The key will be displayed one time and one time only.</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/03.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 3" /></p>

<p>When the key is displayed, click on the key, which copies it to the clipboard. Then, keep it in a safe place, such as a password manager, and click Done.</p>

<h3 id="sender-email">Sender Email</h3>

<p>Every email you send must be sent from a verified email address or domain. To create a sender, click Sender Authentication in the left menu.</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/04.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 4" /></p>

<p>Here you can verify a single address or an entire domain. Verifying the entire domain requires access to DNS settings, so to keep things simple, you will use single address verification.</p>

<p>To achieve this, click Verify a Single Sender on the Sender Authentication page (or Get Started if you don’t have any previously verified email addresses).</p>

<p>You should land on Create a Sender form:</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/05.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 5" /></p>

<p>Fill in the details and click Create. The next step is to wait for the verification email:</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/06.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 6" /></p>

<p>It should momentarily appear in your mailbox. Find the email and click on the Verify Single Sender button in the email:</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/07.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 7" /></p>

<p>This takes you to the confirmation page:</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/08.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 8" /></p>

<p>Now that you have an API key and a valid sender address, you can move on to sending emails.</p>

<h2 id="send-emails-with-sendgrid">Send Emails with SendGrid</h2>

<p>Now that you have an API key, you’ll create a simple .NET application to test sending emails. (You can find the complete source code on this <a href="https://github.com/Dev-Power/sending-emails-with-sendgrid-dynamic-templates">GitHub repository</a>)</p>

<p>First, open a terminal and run the following commands to create a blank Console application and add the <a href="https://www.nuget.org/packages/SendGrid/">SendGrid NuGet package</a>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet new console <span class="nt">-o</span> ConsoleMailer
<span class="nb">cd </span>ConsoleMailer
dotnet add package SendGrid
</code></pre></div></div>

<p>API keys are sensitive information that you should keep secret, so you should avoid hard coding them or committing them to source control. That’s why you should store the API key in an environment variable or a secure vault service. To keep things simple, you will store the API key in an environment variable.</p>

<p>For macOS and Linux, set the environment variable like this:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">SENDGRID_API_KEY</span><span class="o">={</span>your key<span class="o">}</span>
</code></pre></div></div>

<p>If you’re using PowerShell on Windows or another OS, use this command:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$</span><span class="nn">Env</span><span class="p">:</span><span class="nv">SENDGRID_API_KEY</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"{your key}"</span><span class="w">
</span></code></pre></div></div>

<p>If you’re using CMD on Windows, use this command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">set</span> <span class="s2">"SENDGRID_API_KEY={your key}"</span>
</code></pre></div></div>

<p>Replace <code class="language-plaintext highlighter-rouge">{your key}</code> with the API key secret you copied earlier.</p>

<p>Open the project in your preferred editor and find the Program.cs file. Update the Program.cs file with the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">SendGrid</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">SendGrid.Helpers.Mail</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">apiKey</span> <span class="p">=</span> <span class="n">Environment</span><span class="p">.</span><span class="nf">GetEnvironmentVariable</span><span class="p">(</span><span class="s">"SENDGRID_API_KEY"</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">client</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">SendGridClient</span><span class="p">(</span><span class="n">apiKey</span><span class="p">);</span>
<span class="kt">var</span> <span class="k">from</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">EmailAddress</span><span class="p">(</span><span class="s">"{ Your verified email address }"</span><span class="p">,</span> <span class="s">"{ Sender display name }"</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">to</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">EmailAddress</span><span class="p">(</span><span class="s">"{ Recipient email address }"</span><span class="p">,</span> <span class="s">"{ Recipient display name }"</span><span class="p">);</span>
</code></pre></div></div>

<p>The code block above is going to be shared among the examples used in this article. You’ll need to replace <code class="language-plaintext highlighter-rouge">{ Your verified email address }</code> with the SendGrid Single Sender email address you created earlier, and <code class="language-plaintext highlighter-rouge">{ Sender display name }</code> with any name you prefer. The name will be displayed to the recipients. Then replace <code class="language-plaintext highlighter-rouge">{ Recipient email address}</code> and <code class="language-plaintext highlighter-rouge">{ Recipient display name}</code> with your desired recipient email address and name.</p>

<p>Then, append the following code block:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">subject</span> <span class="p">=</span> <span class="s">"Testing the API key"</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">plainTextContent</span> <span class="p">=</span> <span class="s">"Testing a simple email"</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">htmlContent</span> <span class="p">=</span> <span class="s">"&lt;strong&gt;Testing simple email in HTML&lt;/strong&gt;"</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">msg</span> <span class="p">=</span> <span class="n">MailHelper</span><span class="p">.</span><span class="nf">CreateSingleEmail</span><span class="p">(</span><span class="k">from</span><span class="p">,</span> <span class="n">to</span><span class="p">,</span> <span class="n">subject</span><span class="p">,</span> <span class="n">plainTextContent</span><span class="p">,</span> <span class="n">htmlContent</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">await</span> <span class="n">client</span><span class="p">.</span><span class="nf">SendEmailAsync</span><span class="p">(</span><span class="n">msg</span><span class="p">);</span>
<span class="k">if</span> <span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="n">IsSuccessStatusCode</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">"Email has been sent successfully"</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The received email looks like this:</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/09.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 9" /></p>

<p>!!!info</p>

<p>The methods from the <code class="language-plaintext highlighter-rouge">MailHelper</code> class like <code class="language-plaintext highlighter-rouge">CreateSingleEmail</code> all create a <code class="language-plaintext highlighter-rouge">SendGridMessage</code> object. You can also create a <code class="language-plaintext highlighter-rouge">SendGridMessage</code> object yourself, but the <code class="language-plaintext highlighter-rouge">MailHelper</code> class provides some convenient methods to do this for you for common scenario’s.</p>

<p>!!!</p>

<h2 id="dynamic-email-templates">Dynamic Email Templates</h2>

<p>Even though the data inside the email changes from email to email (such as the recipient name), most emails are based on some template. SendGrid has a powerful template designer that you can use to create dynamic templates, but you can also write the templates using code yourself.</p>

<p>To create your templates, navigate to <a href="https://app.sendgrid.com/">the SendGrid Dashboard</a>, click Email API and then Dynamic templates on the left menu. In the Dynamic Templates screen, click the “Create a Dynamic” template button.</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/10.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 10" /></p>

<p>Give the template a name and click Create:</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/11.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 11" /></p>

<p>One nice feature about templates is that they are versioned. So you can create a new version without losing the previous version of the template. This way you can easily roll back to an earlier version.</p>

<p>Create your first version by clicking Add Version:</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/12.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 12" /></p>

<p>SendGrid has a lot of built-in email templates:</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/13.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 13" /></p>

<p>In this example, you’ll create a template from scratch. So, click on Your Email Designs and click Blank Template:</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/14.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 14" /></p>

<p>Now you have to select the editor. Here you have two options: Design Editor and Code Editor. If you already have the HTML of an email template, you can simply switch to the code editor and start using that as a starting point.</p>

<p>In this example, you will use the designer, which simplifies designing email templates quite a bit. You can drag and drop the modules into the designer area. You can even test your emails directly in the designer.</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/15.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 15" /></p>

<p>You can also use test data to ensure it looks good while still designing.</p>

<p>For example, to test this design that uses the <code class="language-plaintext highlighter-rouge">recipientName</code> variable as a placeholder, you can click Preview and Show Test Data. Then you can enter your variable value as JSON and see the output.</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/16.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 16" /></p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/17.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 17" /></p>

<p>At this point, you can design the email template to your liking by using basic modules such as text, image, HTML code, divider, etc.</p>

<p>The “dynamic” part comes from the ability to use placeholders, which then can be replaced with actual values. To understand how that works, you’ll need to understand how the templating support works with SendGrid. SendGrid emails support the <a href="https://docs.sendgrid.com/for-developers/sending-email/using-handlebars">Handlebar templating language</a> (both in transactional templates and marketing campaign designs). Next, you will look into the basics of Handlebars to understand how you can leverage it to create dynamic data-driven templates.</p>

<h2 id="handlebars-templating-language">Handlebars Templating Language</h2>

<p><a href="https://handlebarsjs.com">Handlebars</a> is a commonly used templating language. It’s simple and quite powerful for creating dynamic templates.</p>

<h3 id="variable-substitution">Variable Substitution</h3>

<p>Simple variable replacement is widely used in dynamic templates. As shown in the previous section, you can place a variable in your template by using it between opening and closing double-curly braces, such as:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Hello, {{recipientName}}
</code></pre></div></div>

<p>When you use a template such as this, you’ll need to provide data that includes the value; otherwise, it’s left blank. The nice thing is at least the placeholder is still replaced with an empty string so that it doesn’t appear in the final email, which would look very ugly and amateurish.</p>

<p>In C#, you can leverage normal .NET objects, including anonymous types, to provide dynamic data. For example, to provide data to the template above, you can use an anonymous object like this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">dynamicEmailData</span> <span class="p">=</span> <span class="k">new</span>
<span class="p">{</span>
    <span class="n">recipientName</span> <span class="p">=</span> <span class="s">"Demo User"</span><span class="p">,</span>
<span class="p">};</span>
</code></pre></div></div>

<p>To avoid leaving the variables blank, you can also provide default values by using the insert keyword. For example,</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Hello,{{ insert recipientName "default=Valued User" }}
</code></pre></div></div>

<p>If you don’t provide <code class="language-plaintext highlighter-rouge">recipientName</code> in the data, the output looks like this:</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/18.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 18" /></p>

<h3 id="variable-replacement-in-the-subject-field">Variable Replacement in the Subject Field</h3>

<p>In the designer, you can manually set a subject for your emails, but you can make this field dynamic as well. Variable substitution works for the subject field too. You can set the subject value as a variable using Handlebar notation:</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/19.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 19" /></p>

<p>You are not obligated to replace the entire subject line. You can use a variable inside a longer hard-coded string in your template such as:</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/20.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 20" /></p>

<p>To replace the placeholder with the actual value, in your code, you can just pass the actual value through template data:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">dynamicTemplateData</span> <span class="p">=</span> <span class="k">new</span>
<span class="p">{</span>
    <span class="n">subject</span> <span class="p">=</span> <span class="s">$"To-Do List for </span><span class="p">{</span><span class="n">DateTime</span><span class="p">.</span><span class="n">UtcNow</span><span class="p">:</span><span class="n">MMMM</span><span class="p">}</span><span class="s">"</span>
<span class="p">};</span>
</code></pre></div></div>

<h3 id="html-replacement">HTML Replacement</h3>

<p>You can also provide HTML to be injected into the template as well. The key point is that those values need to be marked with three curly braces. For example, if you want to replace the recipient name with HTML, you would use:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Hello, {{{recipientName}}}
</code></pre></div></div>

<p>And the object we provide would look like this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">dynamicEmailData</span> <span class="p">=</span> <span class="k">new</span>
<span class="p">{</span>
    <span class="n">recipientName</span> <span class="p">=</span> <span class="s">"&lt;b&gt;&lt;i&gt;Demo User&lt;/i&gt;&lt;/b&gt;"</span><span class="p">,</span>
<span class="p">};</span>
</code></pre></div></div>

<p>The output now looks like this:</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/21.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 21" /></p>

<p>!!!warning
Keep in mind that whenever you are using three curly braces, the variable will not be encoded and <a href="https://www.twilio.com/blog/prevent-email-html-injection-in-csharp-and-dotnet">susceptible to HTML injection</a>. If this variable holds user input, this can be risky!
Make sure to use the <code class="language-plaintext highlighter-rouge">HtmlEncoder</code> in .NET to encode user input before passing it into the three curly braces.
!!!</p>

<h3 id="iterations">Iterations</h3>

<p>Working with arrays happens quite often when creating templated emails. Handlebars has support for handling lists and iterating over them using the <code class="language-plaintext highlighter-rouge">each</code> keyword. For example, in the upcoming sample, you will list a number of to-do list items. This can be achieved by the code snippet below:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;table&gt;</span>
  <span class="nt">&lt;tr&gt;</span>
    <span class="nt">&lt;th&gt;</span>Title<span class="nt">&lt;/th&gt;</span>
    <span class="nt">&lt;th&gt;</span>Due Date<span class="nt">&lt;/th&gt;</span>
    <span class="nt">&lt;th&gt;</span>Status<span class="nt">&lt;/th&gt;</span>
  <span class="nt">&lt;/tr&gt;</span>
  {{#each todoItemList}}
    <span class="nt">&lt;tr&gt;</span>
      <span class="nt">&lt;td&gt;</span>{{this.Title}}<span class="nt">&lt;/td&gt;</span>
      <span class="nt">&lt;td&gt;</span>{{this.DueDate}}<span class="nt">&lt;/td&gt;</span>
      <span class="nt">&lt;td&gt;</span>{{this.Status}}<span class="nt">&lt;/td&gt;</span>
    <span class="nt">&lt;/tr&gt;</span>
  {{/each}}  
<span class="nt">&lt;/table&gt;</span>
</code></pre></div></div>

<p>The items in the array can be addressed by using <code class="language-plaintext highlighter-rouge">this</code> keyword, as shown above.</p>

<h3 id="conditionals">Conditionals</h3>

<p>Handlebars Templating Language also has the ability to apply some basic logic using and/or operators, if/else statements, comparison (less than/greater than/equals) and length operator to get the number of characters in a string or the number of items in an array.</p>

<p>Empty strings and zero numbers evaluate to false. For example, the code snippet below shows the <code class="language-plaintext highlighter-rouge">username</code> if the variable is a non-empty string:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{{#if this.username}}
  <span class="nt">&lt;h1&gt;</span>Hello {{username}}<span class="nt">&lt;/h1&gt;</span>
{{/if}}
</code></pre></div></div>

<p>You can also check against the number of array lengths. In the example below, the “You have unread messages in your mailbox!” message will only be shown if the <code class="language-plaintext highlighter-rouge">unreadMessages</code> array has elements in it.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{{#greaterThan (length unreadMessages) 0}}
  <span class="nt">&lt;p&gt;</span>You have unread messages in your mailbox!<span class="nt">&lt;/p&gt;</span>
{{else}}
    <span class="nt">&lt;p&gt;</span>No unread messages.<span class="nt">&lt;/p&gt;</span>
{{/greaterThan}}
</code></pre></div></div>

<p>With the following data, it displays the message:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"unreadMessages"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="s2">"Message 1"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Message 2"</span><span class="w"> </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>But in the case of an empty array, it shows “No unread messages.”.</p>

<h3 id="more-handlebars">More Handlebars</h3>

<p>In addition to the features covered above, you can use conditionals and some basic logic to implement more complicated templates. You can <a href="https://docs.sendgrid.com/for-developers/sending-email/using-handlebars">find out more about using Handlebars with Twilio SendGrid here</a>.</p>

<h2 id="send-email-using-dynamic-email-templates">Send Email Using Dynamic Email Templates</h2>

<h3 id="set-up-dynamic-email-template">Set Up Dynamic Email Template</h3>

<p>First, go to <a href="https://mc.sendgrid.com/dynamic-templates">SendGrid Email templates page</a>.</p>

<p>Then, expand your dynamic template and click on the active version:</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/22.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 22" /></p>

<p>Update the subject field with <code class="language-plaintext highlighter-rouge">{{subject}}</code> as you’re going to replace it with dynamic data.</p>

<p>Remove all the elements in the design area. Your design should look like this:</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/23.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 23" /></p>

<p>On the left pane, click Build. Drag and drop a Text object into the design area. Replace the placeholder text with</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Hello, {{recipientName}}
Here's your to-do list:
</code></pre></div></div>

<p>In this example, I set the line height to 40. You can play around with text properties to your liking.</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/24.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 24" /></p>

<p>Click on anywhere in the design area outside the Text element to see the available elements on the left pane again.</p>

<p>Click Code and drag it under the text element. This automatically opens the HTML editor. Paste the following code in the editor:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;table&gt;</span>
  <span class="nt">&lt;tr&gt;</span>
    <span class="nt">&lt;th&gt;</span>Title<span class="nt">&lt;/th&gt;</span>
    <span class="nt">&lt;th&gt;</span>Due Date<span class="nt">&lt;/th&gt;</span>
    <span class="nt">&lt;th&gt;</span>Status<span class="nt">&lt;/th&gt;</span>
  <span class="nt">&lt;/tr&gt;</span>
  {{#each todoItemList}}
    <span class="nt">&lt;tr&gt;</span>
      <span class="nt">&lt;td&gt;</span>{{this.title}}<span class="nt">&lt;/td&gt;</span>
      <span class="nt">&lt;td&gt;</span>{{this.dueDate}}<span class="nt">&lt;/td&gt;</span>
      <span class="nt">&lt;td&gt;</span>{{this.status}}<span class="nt">&lt;/td&gt;</span>
    <span class="nt">&lt;/tr&gt;</span>
  {{/each}}  
<span class="nt">&lt;/table&gt;</span>
</code></pre></div></div>

<p>Click Update. Your final version of the template should look like this:</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/25.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 25" /></p>

<h3 id="set-up-c-application">Set Up C# Application</h3>

<p>Open the Program.cs file in the ConsoleMailer project you created previously, and replace the previous email sending code with the highlighted Dynamic Email Template code:</p>

<p><code class="language-plaintext highlighter-rouge">csharp hl_lines="9 10 11 12 13 14 15 16 17 18 19 20 21 22"
using SendGrid;
using SendGrid.Helpers.Mail;
var apiKey = Environment.GetEnvironmentVariable("SENDGRID_API_KEY");
var client = new SendGridClient(apiKey);
var from = new EmailAddress("{ Your verified email address }", "{ Sender display name }");
var to = new EmailAddress("{ Recipient email address }", "{ Recipient display name }");
var templateId = "{ Your dynamic template id }";
var dynamicTemplateData = new
{
    subject = $"To-Do List for {DateTime.UtcNow:MMMM}",
    recipientName = "Demo User", 
    todoItemList = new[]
    {
        new { title = "Organize invoices", dueDate = "11 June 2022", status = "Completed" },
        new { title = "Prepare taxes", dueDate = "12 June 2022", status = "In progress" },
        new { title = "Submit taxes", dueDate = "25 June 2022", status = "Pending" },
    }
};
var msg = MailHelper.CreateSingleTemplateEmail(from, to, templateId, dynamicTemplateData);
var response = await client.SendEmailAsync(msg);
if (response.IsSuccessStatusCode)
{
    Console.WriteLine("Email has been sent successfully");
}
</code></p>

<p>!!!info</p>

<p>Make sure the name and casing of the variables in your template match the dynamic template data you’re passing in. For example, if your subject variable is <code class="language-plaintext highlighter-rouge">{{subject}}</code> and your C# data has a property named <code class="language-plaintext highlighter-rouge">Subject</code>, the subject of your email will be empty.</p>

<p>!!!</p>

<p>This code is similar to the previous code with a few differences:</p>

<ul>
  <li>
    <p>You call the <code class="language-plaintext highlighter-rouge">CreateSingleTemplateEmail</code> method instead of the <code class="language-plaintext highlighter-rouge">CreateSingleEmail</code> method</p>
  </li>
  <li>
    <p>You provide the data displayed in the final email output, but you don’t render the HTML yourself.</p>
  </li>
</ul>

<p>Also, you must provide the unique template ID. You can obtain the ID from the <a href="https://mc.sendgrid.com/dynamic-templates">Dynamic Templates page</a>. Expand the details of your template and the Template ID should appear at the top:</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/26.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 26" /></p>

<p>Once you’ve obtained the template ID, replace <code class="language-plaintext highlighter-rouge">{ Your dynamic template id }</code> with it in your code.</p>

<p>The template ID is shared among all versions, so you don’t have to change your configuration when you create a new version. However, you have to ensure to choose the correct version as active. To make a version active, click on the vertical three dots on the right and click Make Active.</p>

<p><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/27.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 27" /></p>

<p>The final email looks like this:</p>

<h2><img src="/images/vpblogimg/2026/08/Send-Emails-with-CSharp-Handlebars-Templating-and-Dynamic-Email-Templates/28.png" alt="Send Emails with C#, Handlebars Templating, and Dynamic Email Templates - image 28" /></h2>

<p>You can see how the <code class="language-plaintext highlighter-rouge">todoItemList</code> array is used to render the to-do item list table in a dynamic fashion.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this post, you looked into the basics of setting up your SendGrid account and the SendGrid .NET library. Then you learned how to use Dynamic Email Templates and how you can use Handlebar templating to create dynamic emails. You can find the source code for the applications in this tutorial on <a href="https://github.com/Dev-Power/sending-emails-with-sendgrid-dynamic-templates">GitHub</a>.</p>

<p>Twilio SendGrid offers a lot more than covered in this article. I’d recommend visiting the <a href="https://docs.sendgrid.com/">official documentation</a> and discovering more features based on your use cases. Here are a couple of articles about sending emails and templating that could help you get the most out of SendGrid and .NET:</p>

<ul>
  <li>
    <p><a href="https://www.twilio.com/blog/use-razor-layouts-in-fluentemail-to-reuse-headers-and-footers">Use Razor Layouts in FluentEmail to reuse Headers and Footers</a></p>
  </li>
  <li>
    <p><a href="https://www.twilio.com/blog/how-to-build-an-email-newsletter-application-using-asp-net-core-and-sendgrid">How to build an Email Newsletter application using ASP.NET Core and SendGrid</a></p>
  </li>
</ul>
]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[How to create an RSS Podcast Feed from local files with C#]]></title>
    <link href="https://volkanpaksoy.com/archive/2025/10/12/How-to-create-an-RSS-Podcast-Feed-from-local-files-with-C/"/>
    <updated>2025-10-12T11:30:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2025/10/12/How-to-create-an-RSS-Podcast-Feed-from-local-files-with-C#</id>
    <content type="html"><![CDATA[<p>I have a few old podcast series that are not available online anymore. Every now and then, I enjoy listening to an old episode. I keep them in a hard drive connected to a Raspberry Pi, which serves them over the local network. Then I connect to this share on my mobile device and consume the content. It works fine most of the time. The problem is it’s hard to remember the last episode I listened to since everything is treated as files with no history. I thought I could leverage my podcast app on my phone if I served these files via an RSS feed. This tutorial will show how to generate the RSS feed using C# and serve the content over your local network. If this sounds like a problem you would like to solve, let’s get started.</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>To follow this tutorial, you need the following software installed:</p>

<ul>
  <li>
    <p><a href="https://docs.docker.com/engine/install/">Docker engine</a></p>
  </li>
  <li>
    <p><a href="https://dotnet.microsoft.com/en-us/download">.NET SDK</a></p>
  </li>
</ul>

<h2 id="set-up-the-web-server">Set up the Web Server</h2>

<p>Since you will host only static files (RSS feed which is an XML file and some audio files), an Nginx instance running in a Docker container is sufficient.</p>

<p>First, designate a local directory on your computer to put the files. In the tutorial, I will use the following path: <code class="language-plaintext highlighter-rouge">~/Temp/webroot</code>. Modify this to match your environment.</p>

<p>Run the following command to start your podcast server:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run <span class="nt">--name</span> podcast-server <span class="nt">-p</span> 9876:80 <span class="nt">-v</span> ~/Temp/webroot:/usr/share/nginx/html:ro <span class="nt">-d</span> nginx
</code></pre></div></div>

<p>The command above;</p>

<ul>
  <li>
    <p>Maps port 9876 on your machine to the internal port 80 in the container. (-p 9876:80)</p>
  </li>
  <li>
    <p>Runs the container in the background as a daemon (-d)</p>
  </li>
  <li>
    <p>Mounts the <code class="language-plaintext highlighter-rouge">~/Temp/webroot</code> directory on your machine to the <code class="language-plaintext highlighter-rouge">/usr/share/nginx/html</code> directory on the container. This means when Nginx serves content in its HTML directory, it looks into the ~/Temp/webroot directory. This way, you can manage the content without going into the container’s file system.</p>
  </li>
</ul>

<p>If you open a browser tab and go to <em>http://localhost:9876</em>, you should get a <em>403 Forbidden</em> response from the web server. This is expected because you haven’t put any files to serve yet.</p>

<h2 id="set-up-content">Set up Content</h2>

<p>To test the application, let’s start with a small amount of content. Go to file-examples.com and download 2 MP3 files and rename them as “episode1.mp3” and “episode2.mp3”. So the root of your web server should look like this:</p>

<p><img src="/images/vpblogimg/2025/10/local-rss/contents.png" alt="Contents of the root directory showing webroot directory, content directory under it and two files named episode1.mp3 and episode2.mp3" /></p>

<p>Now, if you request one of these files in your browser (e.g. <em>http://localhost:9876/content/episode1.mp3</em>), you should be able to hear the MP3 playing. In the next section, you will implement the application that creates the RSS feed so that you can consume the feed via your podcatcher too.</p>

<h2 id="implement-the-rss-generator">Implement the RSS Generator</h2>

<p>An RSS feed is simply an XML file. It includes the name and description of the show, as well as the titles and URLs of the individual episodes. If this feed were meant to be published publicly, you would add more details such as icons, categories, iTunes-specific tags etc., but for personal consumption, the following format is sufficient:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?xml version="1.0" encoding="utf-8"?&gt;</span>
<span class="nt">&lt;rss</span> <span class="na">version=</span><span class="s">"2.0"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;channel&gt;</span>
    <span class="nt">&lt;title&gt;</span>{Show Title}<span class="nt">&lt;/title&gt;</span>
    <span class="nt">&lt;description&gt;</span>{Show Description}<span class="nt">&lt;/description&gt;</span>
    <span class="nt">&lt;category&gt;</span>{Category}<span class="nt">&lt;/category&gt;</span>
    <span class="nt">&lt;item&gt;</span>
      <span class="nt">&lt;title&gt;</span>{Episode Title}<span class="nt">&lt;/title&gt;</span>
      <span class="nt">&lt;description&gt;</span>{Episode Description}<span class="nt">&lt;/description&gt;</span>
      <span class="nt">&lt;enclosure</span> <span class="na">url=</span><span class="s">"{Episode URL}"</span> <span class="na">type=</span><span class="s">"audio/mpeg"</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;/item&gt;</span>
  <span class="nt">&lt;/channel&gt;</span>
<span class="nt">&lt;/rss&gt;</span>
</code></pre></div></div>

<p>Create a new dotnet console application:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet new console <span class="nt">--name</span> RssFeedGenerator <span class="nt">--output</span> <span class="nb">.</span>
</code></pre></div></div>

<p>Open the project with your IDE.</p>

<p>To serialize to the XML above, you will need a data structure. There are tools that can generate C# classes from a sample XML, so you don’t have to manually create the classes yourself. I generated the following at Xml2Charp. The result looks like this (after a bit of formatting):</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* 
 Licensed under the Apache License, Version 2.0
 
 http://www.apache.org/licenses/LICENSE-2.0
 */</span>

<span class="k">using</span> <span class="nn">System.Xml.Serialization</span><span class="p">;</span>

<span class="k">namespace</span> <span class="nn">RssFeedGenerator</span>
<span class="p">{</span>
    <span class="p">[</span><span class="nf">XmlRoot</span><span class="p">(</span><span class="n">ElementName</span><span class="p">=</span><span class="s">"enclosure"</span><span class="p">)]</span>
    <span class="k">public</span> <span class="k">class</span> <span class="nc">Enclosure</span>
    <span class="p">{</span>
        <span class="p">[</span><span class="nf">XmlAttribute</span><span class="p">(</span><span class="n">AttributeName</span><span class="p">=</span><span class="s">"url"</span><span class="p">)]</span>
        <span class="k">public</span> <span class="kt">string</span> <span class="n">Url</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
        <span class="p">[</span><span class="nf">XmlAttribute</span><span class="p">(</span><span class="n">AttributeName</span><span class="p">=</span><span class="s">"type"</span><span class="p">)]</span>
        <span class="k">public</span> <span class="kt">string</span> <span class="n">Type</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="p">}</span>

    <span class="p">[</span><span class="nf">XmlRoot</span><span class="p">(</span><span class="n">ElementName</span><span class="p">=</span><span class="s">"item"</span><span class="p">)]</span>
    <span class="k">public</span> <span class="k">class</span> <span class="nc">Item</span>
    <span class="p">{</span>
        <span class="p">[</span><span class="nf">XmlElement</span><span class="p">(</span><span class="n">ElementName</span><span class="p">=</span><span class="s">"title"</span><span class="p">)]</span>
        <span class="k">public</span> <span class="kt">string</span> <span class="n">Title</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
        <span class="p">[</span><span class="nf">XmlElement</span><span class="p">(</span><span class="n">ElementName</span><span class="p">=</span><span class="s">"description"</span><span class="p">)]</span>
        <span class="k">public</span> <span class="kt">string</span> <span class="n">Description</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
        <span class="p">[</span><span class="nf">XmlElement</span><span class="p">(</span><span class="n">ElementName</span><span class="p">=</span><span class="s">"enclosure"</span><span class="p">)]</span>
        <span class="k">public</span> <span class="n">Enclosure</span> <span class="n">Enclosure</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="p">}</span>

    <span class="p">[</span><span class="nf">XmlRoot</span><span class="p">(</span><span class="n">ElementName</span><span class="p">=</span><span class="s">"channel"</span><span class="p">)]</span>
    <span class="k">public</span> <span class="k">class</span> <span class="nc">Channel</span>
    <span class="p">{</span>
        <span class="p">[</span><span class="nf">XmlElement</span><span class="p">(</span><span class="n">ElementName</span><span class="p">=</span><span class="s">"title"</span><span class="p">)]</span>
        <span class="k">public</span> <span class="kt">string</span> <span class="n">Title</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
        <span class="p">[</span><span class="nf">XmlElement</span><span class="p">(</span><span class="n">ElementName</span><span class="p">=</span><span class="s">"description"</span><span class="p">)]</span>
        <span class="k">public</span> <span class="kt">string</span> <span class="n">Description</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
        <span class="p">[</span><span class="nf">XmlElement</span><span class="p">(</span><span class="n">ElementName</span><span class="p">=</span><span class="s">"category"</span><span class="p">)]</span>
        <span class="k">public</span> <span class="kt">string</span> <span class="n">Category</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
        <span class="p">[</span><span class="nf">XmlElement</span><span class="p">(</span><span class="n">ElementName</span><span class="p">=</span><span class="s">"item"</span><span class="p">)]</span>
        <span class="k">public</span> <span class="n">List</span><span class="p">&lt;</span><span class="n">Item</span><span class="p">&gt;</span> <span class="n">Item</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="p">}</span>

    <span class="p">[</span><span class="nf">XmlRoot</span><span class="p">(</span><span class="n">ElementName</span><span class="p">=</span><span class="s">"rss"</span><span class="p">)]</span>
    <span class="k">public</span> <span class="k">class</span> <span class="nc">Rss</span>
    <span class="p">{</span>
        <span class="p">[</span><span class="nf">XmlElement</span><span class="p">(</span><span class="n">ElementName</span><span class="p">=</span><span class="s">"channel"</span><span class="p">)]</span>
        <span class="k">public</span> <span class="n">Channel</span> <span class="n">Channel</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
        <span class="p">[</span><span class="nf">XmlAttribute</span><span class="p">(</span><span class="n">AttributeName</span><span class="p">=</span><span class="s">"version"</span><span class="p">)]</span>
        <span class="k">public</span> <span class="kt">string</span> <span class="n">Version</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Create a file called <em>Rss.cs</em> in your project and paste the above code. The biggest change I made to the auto-generated version is to replace the single <strong>Item</strong> property in the Channel class with a **List<Item>**, as you will need multiple entries per podcast.</Item></p>

<p>Now, update <em>Program.cs</em> with the code below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">System.Xml.Serialization</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">RssFeedGenerator</span><span class="p">;</span>

<span class="kt">string</span> <span class="n">serverIPAddress</span> <span class="p">=</span> <span class="s">"192.168.1.20"</span><span class="p">;</span>
<span class="kt">int</span> <span class="n">serverPort</span> <span class="p">=</span> <span class="m">9876</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">contentFullPath</span> <span class="p">=</span> <span class="s">"/Temp/webroot/content"</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">feedFullPath</span> <span class="p">=</span> <span class="s">"/Temp/webroot/feed.rss"</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">audioRootUrl</span> <span class="p">=</span> <span class="s">$"http://</span><span class="p">{</span><span class="n">serverIPAddress</span><span class="p">}</span><span class="s">:</span><span class="p">{</span><span class="n">serverPort</span><span class="p">}</span><span class="s">"</span><span class="p">;</span>

<span class="kt">var</span> <span class="n">rss</span> <span class="p">=</span> <span class="k">new</span> <span class="n">Rss</span>
<span class="p">{</span>
    <span class="n">Version</span> <span class="p">=</span> <span class="s">"2.0"</span><span class="p">,</span>
    <span class="n">Channel</span> <span class="p">=</span> <span class="k">new</span> <span class="n">Channel</span>
    <span class="p">{</span>
        <span class="n">Title</span> <span class="p">=</span> <span class="s">"[Local] Test Podcast"</span><span class="p">,</span>
        <span class="n">Description</span> <span class="p">=</span> <span class="s">"Testing generating RSS feed from local MP3 files"</span><span class="p">,</span>
        <span class="n">Category</span> <span class="p">=</span> <span class="s">"test"</span><span class="p">,</span>
        <span class="n">Item</span> <span class="p">=</span> <span class="k">new</span> <span class="n">List</span><span class="p">&lt;</span><span class="n">Item</span><span class="p">&gt;()</span>
    <span class="p">}</span>
<span class="p">};</span>

<span class="kt">var</span> <span class="n">allMp3s</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">DirectoryInfo</span><span class="p">(</span><span class="n">contentFullPath</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">GetFiles</span><span class="p">(</span><span class="s">"*.mp3"</span><span class="p">,</span> <span class="n">SearchOption</span><span class="p">.</span><span class="n">AllDirectories</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">OrderBy</span><span class="p">(</span><span class="n">x</span> <span class="p">=&gt;</span> <span class="n">x</span><span class="p">.</span><span class="n">Name</span><span class="p">);</span>

<span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">mp3</span> <span class="k">in</span> <span class="n">allMp3s</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">rss</span><span class="p">.</span><span class="n">Channel</span><span class="p">.</span><span class="n">Item</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="k">new</span> <span class="n">Item</span>
    <span class="p">{</span>
        <span class="n">Description</span> <span class="p">=</span> <span class="n">mp3</span><span class="p">.</span><span class="n">Name</span><span class="p">,</span>
        <span class="n">Title</span> <span class="p">=</span> <span class="n">mp3</span><span class="p">.</span><span class="n">Name</span><span class="p">,</span>
        <span class="n">Enclosure</span> <span class="p">=</span> <span class="k">new</span> <span class="n">Enclosure</span>
        <span class="p">{</span>
            <span class="n">Url</span> <span class="p">=</span> <span class="s">$"</span><span class="p">{</span><span class="n">audioRootUrl</span><span class="p">}</span><span class="s">/</span><span class="p">{</span><span class="n">mp3</span><span class="p">.</span><span class="n">Directory</span><span class="p">.</span><span class="n">Name</span><span class="p">}</span><span class="s">/</span><span class="p">{</span><span class="n">mp3</span><span class="p">.</span><span class="n">Name</span><span class="p">}</span><span class="s">"</span><span class="p">,</span>
            <span class="n">Type</span> <span class="p">=</span> <span class="s">"audio/mpeg"</span>
        <span class="p">}</span>
    <span class="p">});</span>
<span class="p">}</span>

<span class="kt">var</span> <span class="n">serializer</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">XmlSerializer</span><span class="p">(</span><span class="k">typeof</span><span class="p">(</span><span class="n">Rss</span><span class="p">));</span>
<span class="k">using</span> <span class="p">(</span><span class="kt">var</span> <span class="n">writer</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">StreamWriter</span><span class="p">(</span><span class="n">feedFullPath</span><span class="p">))</span>
<span class="p">{</span>
    <span class="n">serializer</span><span class="p">.</span><span class="nf">Serialize</span><span class="p">(</span><span class="n">writer</span><span class="p">,</span> <span class="n">rss</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Make sure to update the settings at the top of the file before you run the application.</p>

<p>Run the application by running the following command in the terminal:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet run
</code></pre></div></div>

<p>Under your web server’s root directory, you should see the <em>feed.rss</em> file that looks like this:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?xml version="1.0" encoding="utf-8"?&gt;</span>
<span class="nt">&lt;rss</span> <span class="na">xmlns:xsi=</span><span class="s">"http://www.w3.org/2001/XMLSchema-instance"</span> <span class="na">xmlns:xsd=</span><span class="s">"http://www.w3.org/2001/XMLSchema"</span> <span class="na">version=</span><span class="s">"2.0"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;channel&gt;</span>
    <span class="nt">&lt;title&gt;</span>[Local] Test Podcast<span class="nt">&lt;/title&gt;</span>
    <span class="nt">&lt;description&gt;</span>Testing generating RSS feed from local MP3 files<span class="nt">&lt;/description&gt;</span>
    <span class="nt">&lt;category&gt;</span>test<span class="nt">&lt;/category&gt;</span>
    <span class="nt">&lt;item&gt;</span>
      <span class="nt">&lt;title&gt;</span>episode1.mp3<span class="nt">&lt;/title&gt;</span>
      <span class="nt">&lt;description&gt;</span>episode1.mp3<span class="nt">&lt;/description&gt;</span>
      <span class="nt">&lt;enclosure</span> <span class="na">url=</span><span class="s">"http://192.168.1.20:9876/content/episode1.mp3"</span> <span class="na">type=</span><span class="s">"audio/mpeg"</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;/item&gt;</span>
    <span class="nt">&lt;item&gt;</span>
      <span class="nt">&lt;title&gt;</span>episode2.mp3<span class="nt">&lt;/title&gt;</span>
      <span class="nt">&lt;description&gt;</span>episode2.mp3<span class="nt">&lt;/description&gt;</span>
      <span class="nt">&lt;enclosure</span> <span class="na">url=</span><span class="s">"http://192.168.1.20:9876/content/episode2.mp3"</span> <span class="na">type=</span><span class="s">"audio/mpeg"</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;/item&gt;</span>
  <span class="nt">&lt;/channel&gt;</span>
<span class="nt">&lt;/rss&gt;</span>
</code></pre></div></div>

<p>At this point, you have your RSS feed and all your content under your web server. The final step is to consume this content using your podcast app.</p>

<h2 id="add-your-podcast-to-your-podcast-app">Add Your Podcast to your Podcast App</h2>

<p>My podcast app of choice is Overcast. I’m very happy with it and have been using it for many years. The following might be a limitation of Overcast, but apparently, it cannot access feeds over the local network. So to tackle this issue, I used <a href="https://ngrok.com/">NGrok</a> to tunnel web traffic to my local web server.</p>

<p>If you are having the same issue, install ngrok and run the following command:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ngrok http 9876
</code></pre></div></div>

<p>This should generate a public URL and route traffic to your local server. In my case, it looks like this:</p>

<p><img src="/images/vpblogimg/2025/10/local-rss/ngrok.png" alt="ngrok output showing the traffic is routed to localhost:9876" /></p>

<p>Now you can access your feed via the <strong>{public URL}/feed.rss</strong>.</p>

<p>In Overcast, I add the URL by clicking the <strong>+ button on the</strong> top right and then clicking <strong>Add URL</strong> link.</p>

<p><img src="/images/vpblogimg/2025/10/local-rss/adding-podcast.png" alt="" /></p>

<p>After it fetches and parses the RSS feed, it should appear in your podcast list.</p>

<p>The only thing that’s left is to open the podcast and play the episodes:</p>

<p><img src="/images/vpblogimg/2025/10/local-rss/podcast.png" alt="" /></p>

<p>Even though Overcast cannot fetch the RSS feed over the local network, it can still play the episodes locally. You can stop ngrok and continue to play the episodes. The downside of this approach is if this podcast is an active one and you want to refresh the feed, you will need to delete and re-add the feed because the ngrok address will have changed the next time you try to get the updates.</p>

<h2 id="conclusion">Conclusion</h2>

<p>I love hosting my own content in my own network. Even though having some offline podcasts stored locally is not a common use case, I had to implement my solution to solve the issue and decided to make it public and share it in case anyone else would like to use the same approach or build on it and make it better. The final source code can be found in my <a href="https://github.com/Dev-Power/rss-podcast-feed-from-local-files">GitHub repository</a>.</p>
]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[How to manage Google Sheets with C#]]></title>
    <link href="https://volkanpaksoy.com/archive/2025/10/11/How-to-manage-Google-Sheets-with-C/"/>
    <updated>2025-10-11T09:00:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2025/10/11/How-to-manage-Google-Sheets-with-C#</id>
    <content type="html"><![CDATA[<p>Spreadsheets are quite powerful tools. They can also act as a simple database with an intuitive UI. You can use the spreadsheet as a temporary database and GSheets API as a CRUD API while you prototype your own application. This article will teach you how to manage Google Sheets using your C# application.</p>

<h2 id="prepare-your-spreadsheet">Prepare your spreadsheet</h2>

<p>The sample application will be a simple shopping list manager console application. It will insert/update/delete items in the shopping list and keep a log of each event in another sheet.</p>

<p>You will need a Google Account to follow along. You can sign up for free <a href="https://accounts.google.com/signup">here</a> if you don’t have one.</p>

<p>While logged in to your Google account, open your <a href="https://drive.google.com/">Google Drive</a>.</p>

<p>Click the <strong>New button</strong> on the top left, then click <strong>Google Sheets</strong> in the menu.</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/new-spreadsheet.png" alt="New menu shows available google services such as Google Docs, Google Sheets, Google Slides, Google Forms and a more link at the end" /></p>

<p>Name your spreadsheet <strong>Shopping List</strong>.</p>

<p>At the bottom of the screen, right-click on the sheet name (Sheet1) and rename it to <strong>Cart.</strong></p>

<p>Set the value of A1 to Item and B1 to Quantity. You can style the cells the way you like.</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/cart-sheet-headers.png" alt="Spreadsheet showing the title Shopping List, the value of A1 cell as &quot;Item&quot; and the value of B1 cell as &quot;Quantity&quot;" /></p>

<p>Now that the “database” is ready, move on to the next section to set up the permissions.</p>

<h2 id="generate-credentials">Generate Credentials</h2>

<p>Go to <a href="https://console.cloud.google.com/">Google API Console</a>.</p>

<p>Click <strong>APIs &amp; Services</strong> and <strong>Library</strong></p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/cred-01.png" alt="" /></p>

<p>Search <strong>sheets</strong> and click <strong>Google Sheets API</strong> in the search results.</p>

<p>In the API settings, click the <strong>Enable</strong> button.</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/cred-02.png" alt="" /></p>

<p>Click the <strong>Create Credentials</strong> button.</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/cred-03.png" alt="" /></p>

<p>In the credential type, select <strong>Application Data</strong>.</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/cred-04.png" alt="" /></p>

<p>It will ask if you’re planning to use it with Kubernetes etc. Select <strong>“No, I’m not using them”</strong>.</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/cred-05.png" alt="" /></p>

<p>Click <strong>Next</strong>.</p>

<p>In the service account settings, enter <strong>shopping-list-service-account</strong> as the service account name and click <strong>Create and Continue</strong>.</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/cred-06.png" alt="" /></p>

<p>Click <strong>Continue</strong> to proceed.</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/cred-07.png" alt="" /></p>

<p>Click <strong>Done</strong> to finish the account creation.</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/cred-08.png" alt="" /></p>

<p>Click <strong>Credentials</strong> and the <strong>new service account</strong>.</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/cred-09.png" alt="" /></p>

<p>Click <strong>Keys</strong> and <strong>Add Key</strong>.</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/cred-10.png" alt="" /></p>

<p>Click <strong>Create new key</strong>, keep <strong>JSON</strong> as the selected option and click <strong>Create</strong>.</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/cred-11.png" alt="" /></p>

<p>A download should automatically start with your credentials. You will need this file later on.</p>

<p>Finally, you need to share your spreadsheet with the new service account. Go to your sheet and click the <strong>Share</strong> button.</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/share-01.png" alt="" /></p>

<p>Copy th<strong>e email address generated for your service account</strong> and paste it into the <strong>Add people and groups</strong> textbox.</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/share-02.png" alt="" /></p>

<p>Click the <strong>Share</strong> button and close the dialog.</p>

<p>Now you can proceed to create the application.</p>

<h2 id="implement-the-sample-application">Implement the sample application</h2>

<p>In a terminal, navigate to the root directory that you want to create the project in and run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet new console <span class="nt">--name</span> ShoppingList <span class="nt">--output</span> <span class="nb">.</span>
</code></pre></div></div>

<p>Add the necessary Google Sheets SDK, via NuGet:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package Google.Apis.Sheets.v4
</code></pre></div></div>

<p>Copy the downloaded credentials to the project folder and open the project with your IDE.</p>

<p>To access your spreadsheet fro myour program, you will need the id of the sheet which you can find in the URL:</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/sheet-id.png" alt="" /></p>

<p>First things first: Confirm your access to your spreadsheet. To achieve that, replace the code in <em>Program.cs</em> with the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Google.Apis.Auth.OAuth2</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Google.Apis.Services</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Google.Apis.Sheets.v4</span><span class="p">;</span>

<span class="kt">var</span> <span class="n">spreadsheetId</span> <span class="p">=</span> <span class="s">"{ YOUR SPREADSHEET'S ID }"</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">range</span> <span class="p">=</span> <span class="s">"Cart!A1:B"</span><span class="p">;</span>

<span class="n">GoogleCredential</span> <span class="n">credential</span><span class="p">;</span>
<span class="k">using</span> <span class="p">(</span><span class="kt">var</span> <span class="n">stream</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">FileStream</span><span class="p">(</span><span class="s">"credentials.json"</span><span class="p">,</span> <span class="n">FileMode</span><span class="p">.</span><span class="n">Open</span><span class="p">,</span> <span class="n">FileAccess</span><span class="p">.</span><span class="n">Read</span><span class="p">))</span>
<span class="p">{</span>
    <span class="n">credential</span> <span class="p">=</span> <span class="n">GoogleCredential</span><span class="p">.</span><span class="nf">FromStream</span><span class="p">(</span><span class="n">stream</span><span class="p">).</span><span class="nf">CreateScoped</span><span class="p">(</span><span class="k">new</span> <span class="kt">string</span><span class="p">[]</span> <span class="p">{</span> <span class="n">SheetsService</span><span class="p">.</span><span class="n">Scope</span><span class="p">.</span><span class="n">Spreadsheets</span> <span class="p">});</span>
<span class="p">}</span>

<span class="kt">var</span> <span class="n">sheetsService</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">SheetsService</span><span class="p">(</span><span class="k">new</span> <span class="n">BaseClientService</span><span class="p">.</span><span class="nf">Initializer</span><span class="p">()</span>
<span class="p">{</span>
    <span class="n">HttpClientInitializer</span> <span class="p">=</span> <span class="n">credential</span><span class="p">,</span>
    <span class="n">ApplicationName</span> <span class="p">=</span> <span class="s">"ShoppingList"</span>
<span class="p">});</span>

<span class="n">SpreadsheetsResource</span><span class="p">.</span><span class="n">ValuesResource</span><span class="p">.</span><span class="n">GetRequest</span> <span class="n">getRequest</span> <span class="p">=</span> <span class="n">sheetsService</span><span class="p">.</span><span class="n">Spreadsheets</span><span class="p">.</span><span class="n">Values</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="n">spreadsheetId</span><span class="p">,</span> <span class="n">range</span><span class="p">);</span>
       
<span class="kt">var</span> <span class="n">getResponse</span> <span class="p">=</span> <span class="k">await</span> <span class="n">getRequest</span><span class="p">.</span><span class="nf">ExecuteAsync</span><span class="p">();</span>
<span class="n">IList</span><span class="p">&lt;</span><span class="n">IList</span><span class="p">&lt;</span><span class="n">Object</span><span class="p">&gt;&gt;</span> <span class="n">values</span> <span class="p">=</span> <span class="n">getResponse</span><span class="p">.</span><span class="n">Values</span><span class="p">;</span>
<span class="k">if</span> <span class="p">(</span><span class="n">values</span> <span class="p">!=</span> <span class="k">null</span> <span class="p">&amp;&amp;</span> <span class="n">values</span><span class="p">.</span><span class="n">Count</span> <span class="p">&gt;</span> <span class="m">0</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">row</span> <span class="k">in</span> <span class="n">values</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="n">row</span><span class="p">[</span><span class="m">0</span><span class="p">]);</span>
        <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="n">row</span><span class="p">[</span><span class="m">1</span><span class="p">]);</span>
    <span class="p">}</span>
<span class="p">}</span>

</code></pre></div></div>

<p>Replace <code class="language-plaintext highlighter-rouge">{ YOUR SPREADSHEET'S ID }</code> with the actual value and run the application. The column titles (Item and Quantity) should be displayed in your terminal.</p>

<p>You can add some items to your shopping list and test again.</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/cart-with-items-01.png" alt="" /></p>

<p>Before going further, refactor the code. You will encapsulate all GSheets-related functions in a called <em>GSheetsHelper.cs</em>. Create the file and update the code as below:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>using Google.Apis.Auth.OAuth2;
using Google.Apis.Services;
using Google.Apis.Sheets.v4;

namespace ShoppingList;

public class GSheetsHelper
{
    private SheetsService _sheetsService;
    private string _spreadsheetId = "{ YOUR SPREADSHEET'S ID }";
    private string _range = "Cart!A2:B";
    
    public GSheetsHelper()
    {
        GoogleCredential credential;
        using (var stream = new FileStream("credentials.json", FileMode.Open, FileAccess.Read))
        {
            credential = GoogleCredential.FromStream(stream).CreateScoped(SheetsService.Scope.Spreadsheets);
        }

        _sheetsService = new SheetsService(new BaseClientService.Initializer()
        {
            HttpClientInitializer = credential,
            ApplicationName = "ShoppingList"
        });        
    }

    public async Task PrintCartItems()
    {
        SpreadsheetsResource.ValuesResource.GetRequest getRequest = _sheetsService.Spreadsheets.Values.Get(_spreadsheetId, _range);
       
        var getResponse = await getRequest.ExecuteAsync();
        IList&lt;IList&lt;Object&gt;&gt; values = getResponse.Values;
        if (values != null &amp;&amp; values.Count &gt; 0)
        {
            Console.WriteLine("Item\t\t\tQuantity");
            
            foreach (var row in values)
            {
                Console.WriteLine($"{row[0]}\t\t\t{row[1]}");
            }
        }
    }
}
</code></pre></div></div>

<p>Run the application now and you should see your items in your cart printed in your terminal:</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/output-01.png" alt="" /></p>

<h3 id="convert-the-application-to-a-cli">Convert the application to a CLI</h3>

<p>You now have the functionality to get your cart, but it will do the same thing every time. To add more commands, convert your application into a CLI. First, add the <strong>System.CommandLine</strong> package to your project:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package System.CommandLine <span class="nt">--version</span> 2.0.0-beta4.22272.1
</code></pre></div></div>

<p>If you are interested in developing your own CLIs with C#, make sure to check out these articles as well: <a href="https://https://volkanpaksoy.com/archive/2025/10/02/develop-your-own-cli-with-csharp">Develop your own CLI with C#</a> and <a href="https://https://volkanpaksoy.com/archive/2025/10/07/how-to-develop-an-interactive-cli-with-csharp">How to Develop an Interactive CLI with C# and dotnet 6.0</a></p>

<p>Create your first command, the same functionality as above, to print the cart items. Replace <em>Program.cs</em> with the following code:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>using System.CommandLine;
using ShoppingList;

var rootCommand = new RootCommand("Manage your shopping cart");

var printCartCommand = new Command("print", "Print the items in the cart");
printCartCommand.SetHandler(async () =&gt;
{
    var gsheetsHelper = new GSheetsHelper();
    try
    {
        await gsheetsHelper.PrintCartItems();
    }
    catch (Exception e)
    {
        Console.Error.WriteLine(e.Message);
    }
});
rootCommand.AddCommand(printCartCommand);

return rootCommand.InvokeAsync(args).Result;
</code></pre></div></div>

<p>Run the application with <code class="language-plaintext highlighter-rouge">dotnet run</code> command and you should now see an info message explaining the supported commands:</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/output-02.png" alt="" /></p>

<p>You now have to specify the command name to print the items. Run it as <code class="language-plaintext highlighter-rouge">dotnet run print</code> to pass the command name and you should see the contents of your cart again.</p>

<h3 id="add-items-yo-your-cart">Add Items yo your Cart</h3>

<p>The program can now be enhanced simply by adding more commands.</p>

<p>Add the following function to <em>GSheetsHelper.cs</em>:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">AddItem</span><span class="p">(</span><span class="kt">string</span> <span class="n">itemName</span><span class="p">,</span> <span class="kt">decimal</span> <span class="n">quantity</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">valuesToInsert</span> <span class="p">=</span> <span class="k">new</span> <span class="n">List</span><span class="p">&lt;</span><span class="kt">object</span><span class="p">&gt;</span>
    <span class="p">{</span>
        <span class="n">itemName</span><span class="p">,</span>
        <span class="n">quantity</span>
    <span class="p">};</span>

    <span class="n">SpreadsheetsResource</span><span class="p">.</span><span class="n">ValuesResource</span><span class="p">.</span><span class="n">AppendRequest</span><span class="p">.</span><span class="n">ValueInputOptionEnum</span> <span class="n">valueInputOption</span> <span class="p">=</span> <span class="n">SpreadsheetsResource</span><span class="p">.</span><span class="n">ValuesResource</span><span class="p">.</span><span class="n">AppendRequest</span><span class="p">.</span><span class="n">ValueInputOptionEnum</span><span class="p">.</span><span class="n">RAW</span><span class="p">;</span>
    <span class="n">SpreadsheetsResource</span><span class="p">.</span><span class="n">ValuesResource</span><span class="p">.</span><span class="n">AppendRequest</span><span class="p">.</span><span class="n">InsertDataOptionEnum</span> <span class="n">insertDataOption</span> <span class="p">=</span> <span class="n">SpreadsheetsResource</span><span class="p">.</span><span class="n">ValuesResource</span><span class="p">.</span><span class="n">AppendRequest</span><span class="p">.</span><span class="n">InsertDataOptionEnum</span><span class="p">.</span><span class="n">INSERTROWS</span><span class="p">;</span>

    <span class="kt">var</span> <span class="n">requestBody</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">ValueRange</span><span class="p">();</span>
    <span class="n">requestBody</span><span class="p">.</span><span class="n">Values</span> <span class="p">=</span> <span class="k">new</span> <span class="n">List</span><span class="p">&lt;</span><span class="n">IList</span><span class="p">&lt;</span><span class="kt">object</span><span class="p">&gt;&gt;();</span>
    <span class="n">requestBody</span><span class="p">.</span><span class="n">Values</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="n">valuesToInsert</span><span class="p">);</span>

    <span class="n">SpreadsheetsResource</span><span class="p">.</span><span class="n">ValuesResource</span><span class="p">.</span><span class="n">AppendRequest</span> <span class="n">appendRequest</span> <span class="p">=</span> <span class="n">_sheetsService</span><span class="p">.</span><span class="n">Spreadsheets</span><span class="p">.</span><span class="n">Values</span><span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="n">requestBody</span><span class="p">,</span> <span class="n">_spreadsheetId</span><span class="p">,</span> <span class="n">_range</span><span class="p">);</span>
    <span class="n">appendRequest</span><span class="p">.</span><span class="n">ValueInputOption</span> <span class="p">=</span> <span class="n">valueInputOption</span><span class="p">;</span>
    <span class="n">appendRequest</span><span class="p">.</span><span class="n">InsertDataOption</span> <span class="p">=</span> <span class="n">insertDataOption</span><span class="p">;</span>
    
    <span class="k">await</span> <span class="n">appendRequest</span><span class="p">.</span><span class="nf">ExecuteAsync</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p>To invoke this method, add the command to <em>Program.cs</em>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>var itemNameOption = new Option&lt;string&gt;(
    new[] {"--item-name", "-n"},
    description: "The name of the item"
);
itemNameOption.IsRequired = true;

var quantityOption = new Option&lt;decimal&gt;(
    new[] {"--quantity", "-q"},
    description: "The quantity of the item"
);
quantityOption.IsRequired = true;

var addItemCommand = new Command("add", "Add an item to the cart")
{
    itemNameOption,
    quantityOption
};
addItemCommand.SetHandler(async (itemName, quantity) =&gt;
{
    var gsheetsHelper = new GSheetsHelper();
    try
    {
        await gsheetsHelper.AddItem(itemName, quantity);
    }
    catch (Exception e)
    {
        Console.Error.WriteLine(e.Message);
    }
}, itemNameOption, quantityOption);
rootCommand.AddCommand(addItemCommand);
</code></pre></div></div>

<p>Run the command with some item like this:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet run add <span class="nt">--item-name</span> Steaks <span class="nt">--quantity</span> 2
</code></pre></div></div>

<p>You should see the new item in your cart:</p>

<p><img src="/images/vpblogimg/2025/10/manage-gsheets-with-csharp/cart-after-insert.png" alt="" /></p>

<h3 id="remove-items-from-your-cart">Remove Items From Your Cart</h3>

<p>To have remove functionality, add the following method to GSheetsHelper:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">RemoveItem</span><span class="p">(</span><span class="kt">string</span> <span class="n">itemName</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">SpreadsheetsResource</span><span class="p">.</span><span class="n">ValuesResource</span><span class="p">.</span><span class="n">GetRequest</span> <span class="n">getRequest</span> <span class="p">=</span> <span class="n">_sheetsService</span><span class="p">.</span><span class="n">Spreadsheets</span><span class="p">.</span><span class="n">Values</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="n">_spreadsheetId</span><span class="p">,</span> <span class="n">_range</span><span class="p">);</span>
    
    <span class="kt">var</span> <span class="n">getResponse</span> <span class="p">=</span> <span class="k">await</span> <span class="n">getRequest</span><span class="p">.</span><span class="nf">ExecuteAsync</span><span class="p">();</span>
    <span class="n">IList</span><span class="p">&lt;</span><span class="n">IList</span><span class="p">&lt;</span><span class="n">Object</span><span class="p">&gt;&gt;</span> <span class="n">values</span> <span class="p">=</span> <span class="n">getResponse</span><span class="p">.</span><span class="n">Values</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">values</span> <span class="p">!=</span> <span class="k">null</span> <span class="p">&amp;&amp;</span> <span class="n">values</span><span class="p">.</span><span class="n">Count</span> <span class="p">&gt;</span> <span class="m">0</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="p">=</span> <span class="m">0</span><span class="p">;</span> <span class="n">i</span> <span class="p">&lt;</span> <span class="n">values</span><span class="p">.</span><span class="n">Count</span><span class="p">;</span> <span class="n">i</span><span class="p">++)</span>
        <span class="p">{</span>
            <span class="k">if</span> <span class="p">(</span><span class="n">values</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="m">0</span><span class="p">].</span><span class="nf">ToString</span><span class="p">()</span> <span class="p">==</span> <span class="n">itemName</span><span class="p">)</span>
            <span class="p">{</span>
                <span class="kt">var</span> <span class="n">request</span> <span class="p">=</span> <span class="k">new</span> <span class="n">Request</span>
                <span class="p">{</span>
                    <span class="n">DeleteDimension</span> <span class="p">=</span> <span class="k">new</span> <span class="n">DeleteDimensionRequest</span>
                    <span class="p">{</span>
                        <span class="n">Range</span> <span class="p">=</span> <span class="k">new</span> <span class="n">DimensionRange</span>
                        <span class="p">{</span>
                            <span class="n">SheetId</span> <span class="p">=</span> <span class="m">0</span><span class="p">,</span>
                            <span class="n">Dimension</span> <span class="p">=</span> <span class="s">"ROWS"</span><span class="p">,</span>
                            <span class="n">StartIndex</span> <span class="p">=</span> <span class="n">i</span> <span class="p">+</span> <span class="m">1</span><span class="p">,</span>
                            <span class="n">EndIndex</span> <span class="p">=</span> <span class="n">i</span> <span class="p">+</span> <span class="m">2</span>
                        <span class="p">}</span>
                    <span class="p">}</span>
                <span class="p">};</span>
                
                <span class="kt">var</span> <span class="n">deleteRequest</span> <span class="p">=</span> <span class="k">new</span> <span class="n">BatchUpdateSpreadsheetRequest</span> <span class="p">{</span><span class="n">Requests</span> <span class="p">=</span> <span class="k">new</span> <span class="n">List</span><span class="p">&lt;</span><span class="n">Request</span><span class="p">&gt;</span> <span class="p">{</span><span class="n">request</span><span class="p">}};</span>
                <span class="kt">var</span> <span class="n">batchUpdateRequest</span> <span class="p">=</span> <span class="k">new</span> <span class="n">SpreadsheetsResource</span><span class="p">.</span><span class="nf">BatchUpdateRequest</span><span class="p">(</span><span class="n">_sheetsService</span><span class="p">,</span> <span class="n">deleteRequest</span><span class="p">,</span> <span class="n">_spreadsheetId</span><span class="p">);</span>
                <span class="k">await</span> <span class="n">batchUpdateRequest</span><span class="p">.</span><span class="nf">ExecuteAsync</span><span class="p">();</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Similar to add command, define it in Program.cs by adding the following code block:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">removeItemCommand</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">Command</span><span class="p">(</span><span class="s">"remove"</span><span class="p">,</span> <span class="s">"Remove an item from the cart"</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">itemNameOption</span>
<span class="p">};</span>
<span class="n">removeItemCommand</span><span class="p">.</span><span class="nf">SetHandler</span><span class="p">(</span><span class="k">async</span> <span class="p">(</span><span class="n">itemName</span><span class="p">)</span> <span class="p">=&gt;</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">gsheetsHelper</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">GSheetsHelper</span><span class="p">();</span>
    <span class="k">try</span>
    <span class="p">{</span>
        <span class="k">await</span> <span class="n">gsheetsHelper</span><span class="p">.</span><span class="nf">RemoveItem</span><span class="p">(</span><span class="n">itemName</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">catch</span> <span class="p">(</span><span class="n">Exception</span> <span class="n">e</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">Console</span><span class="p">.</span><span class="n">Error</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="n">e</span><span class="p">.</span><span class="n">Message</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">},</span> <span class="n">itemNameOption</span><span class="p">);</span>
<span class="n">rootCommand</span><span class="p">.</span><span class="nf">AddCommand</span><span class="p">(</span><span class="n">removeItemCommand</span><span class="p">);</span>
</code></pre></div></div>

<p>Run the application as <code class="language-plaintext highlighter-rouge">dotnet run remove -n Milk</code>, and you should see the item removed from your cart.</p>

<h2 id="conclusion">Conclusion</h2>

<p>This article covered the basics of setting up a new Google Sheets spreadsheet, creating API credentials and granting access to the sheet. It also showed how to develop a basic CLI to list, add and remove items from the spreadsheet. You can get the final version of the application from my <a href="https://github.com/Dev-Power/manage-gsheets-with-csharp">GitHub repo</a>.</p>

<p>Even though it’s a simple project, I hope it helped you learn the basics of managing a Google Sheets spreadsheet.</p>

]]></content>
  </entry>
  
  
  
  <entry>
    <title type="html"><![CDATA[Scheduled MagPi Magazine Tracker with C#]]></title>
    <link href="https://volkanpaksoy.com/archive/2025/10/10/Scheduled-MagPi-Magazine-Tracker-with-C/"/>
    <updated>2025-10-10T10:00:00+00:00</updated>
    <id>https://volkanpaksoy.com/archive/2025/10/10/Scheduled-MagPi-Magazine-Tracker-with-C#</id>
    <content type="html"><![CDATA[<p>There is a slightly different version of this article published recently on Twilio Blog: <a href="https://www.twilio.com/blog/get-notified-of-new-magazine-issues-using-web-scraping-and-sms-with-csharp-dotnet">Get notified of new magazine issues using web scraping and SMS with C# .NET</a>. That version uses a worker service rather than a scheduled console application and uses SMS as the notification channel. The article you’re about to read has the extra step of importing the PDFs into Calibre. If you’re interested in the topic, I recommend checking out both versions.</p>

<p>As a Raspberry PI fan, I like to read <a href="https://magpi.raspberrypi.com/">The MagPi Magazine</a>, which is freely available as PDFs. The problem is I tend to forget to download it manually every month, so I decided to automate the process. I use Calibre as my ebook management software (I blogged about my setup <a href="https://myhomelab.rocks/host-your-ebook-library-with-calibre-on-raspberry-pi/">here</a>).</p>

<h2 id="the-magpi-magazine-tracker-architecture-and-workflow">The MagPi Magazine Tracker Architecture and Workflow</h2>

<p>I wanted this project to periodically check the latest issue, download it when it’s available and import it into Calibre and send me a notification email so that I can connect to my ebook library and check out the issue. So here’s the architecture to achieve this goal:</p>

<p><img src="/images/vpblogimg/2025/10/magpi-tracker/01-magpi-tracker-architecture.png" alt="The MagPi Magazine Tracker Architecture and Workflow diagram" /></p>

<p>Here’s the workflow:</p>

<ol>
  <li>The application is triggered based on a schedule. Since MagPi is a monthly magazine, it should be fine to run it every week.</li>
  <li>The application fetches the MagPi page and parses the HTML to find out the latest issue.</li>
  <li>It then checks its database (a flat file or a JSON would suffice for this project).</li>
  <li>If it’s a new issue, it then downloads the PDF to the local file system.</li>
  <li>It imports the PDF into Calibre using Calibre’s CLI.</li>
  <li>It sends a notification telling the user that a new issue is available.</li>
  <li>The user (which is me!) connects to <a href="https://hub.docker.com/r/linuxserver/calibre-web">calibre-web</a> (the web frontend I used to view my Calibre libraries) and reads the magazine.</li>
</ol>

<h2 id="prerequisites">Prerequisites</h2>

<p>The full source code is freely available on my <a href="https://github.com/Dev-Power/scheduled-magpi-magazine-tracker">GitHub repo</a> if you’re just interested in getting a copy of the application and playing around with it. If you’re new to GitHub, you might want to have a look at this article: <a href="https://volkanpaksoy.com/archive/2025/10/06/how-to-clone-a-github-repository/">How to clone a GitHub repository</a>.</p>

<p>To follow along and implement the project, you will need the following:</p>

<ul>
  <li><a href="https://calibre-ebook.com/download">Calibre</a></li>
  <li>A Twilio SendGrid account (with an API key and sender address set up. The beginning of <a href="https://www.twilio.com/blog/send-emails-with-csharp-handlebars-templating-and-dynamic-email-templates">this article</a> may be useful to set up your account)</li>
</ul>

<h2 id="implementation">Implementation</h2>

<p>For a scheduled task, you generally have two options:</p>

<ul>
  <li>External scheduler: This is generally part of the operating system (such as Task Scheduler for Windows and Crontab for macOS/Linux)</li>
  <li>Internal scheduler: Run the main application in an infinite loop with sleeping the amount of time you want to wait for the next run.</li>
</ul>

<p>I think an internal scheduler works better if your application needs to run quite often, like every hour or so. Using an external scheduler makes more sense to me for longer cycles, like running an application once a week, such as this project. Therefore, I’m going to implement it using a Console Application and schedule it using Crontab. If you are on Windows, you can take a look at <a href="https://www.windowscentral.com/how-create-automated-task-using-task-scheduler-windows-10">this</a> article or search for scheduling tasks on Windows.</p>

<p>To create a Console Application, run the following commands at the root level of your project:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir </span>MagPiTracker
<span class="nb">cd </span>MagPiTracker
dotnet new console
</code></pre></div></div>

<h3 id="persistence">Persistence</h3>

<p>Let’s start with the persistence layer. All you need to read/write is the latest issue you saved in your Calibre library, so create a folder called Persistence and add an interface named <em>IMagPiRepository.cs</em> that looks like this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">MagPiTracker.Persistence</span><span class="p">;</span>

<span class="k">public</span> <span class="k">interface</span> <span class="nc">IMagPiRepository</span>
<span class="p">{</span>
    <span class="n">Task</span><span class="p">&lt;</span><span class="kt">int</span><span class="p">&gt;</span> <span class="nf">GetLastSavedIssueNumber</span><span class="p">();</span>
    <span class="n">Task</span> <span class="nf">SaveLastSavedIssueNumber</span><span class="p">(</span><span class="kt">int</span> <span class="n">newIssueNumber</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The actual implementation is going to be a simple JSON reader/writer for this project. You can choose to implement a SQLite database or a simple txt file. For JSON, add the <em>Newtonsoft.Json</em> package to your project by running:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package NewtonSoft.Json
</code></pre></div></div>

<p>Then, create a file called db.json and set the initial value to 0:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"lastSavedIssueNumber"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Also, make sure that it is going to be copied to the output directory. Right-click on properties and set <strong>Copy to output directory</strong> to <strong>Copy always</strong>. I prefer Copy always to Copy if newer because it’s more straightforward and predictable.</p>

<p>Create a new file named <em>JsonMagPiRepository.cs</em> under the <em>Persistence</em> folder. Update the code as below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Newtonsoft.Json.Linq</span><span class="p">;</span>

<span class="k">namespace</span> <span class="nn">MagPiTracker.Persistence</span><span class="p">;</span>

<span class="k">public</span> <span class="k">class</span> <span class="nc">JsonMagPiRepository</span> <span class="p">:</span> <span class="n">IMagPiRepository</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">const</span> <span class="kt">string</span> <span class="n">DB_PATH</span> <span class="p">=</span> <span class="s">"./persistence/db.json"</span><span class="p">;</span>

    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">int</span><span class="p">&gt;</span> <span class="nf">GetLastSavedIssueNumber</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">rawContents</span> <span class="p">=</span> <span class="k">await</span> <span class="n">File</span><span class="p">.</span><span class="nf">ReadAllTextAsync</span><span class="p">(</span><span class="n">DB_PATH</span><span class="p">);</span>
        <span class="k">return</span> <span class="n">JObject</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="n">rawContents</span><span class="p">).</span><span class="nf">GetValue</span><span class="p">(</span><span class="s">"lastSavedIssueNumber"</span><span class="p">).</span><span class="n">Value</span><span class="p">&lt;</span><span class="kt">int</span><span class="p">&gt;();</span>
    <span class="p">}</span>

    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">SaveLastSavedIssueNumber</span><span class="p">(</span><span class="kt">int</span> <span class="n">newIssueNumber</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">newValue</span> <span class="p">=</span> <span class="k">new</span> <span class="p">{</span> <span class="n">lastSavedIssueNumber</span> <span class="p">=</span> <span class="n">newIssueNumber</span> <span class="p">};</span>
        <span class="k">await</span> <span class="n">File</span><span class="p">.</span><span class="nf">WriteAllTextAsync</span><span class="p">(</span><span class="n">DB_PATH</span><span class="p">,</span> <span class="n">Newtonsoft</span><span class="p">.</span><span class="n">Json</span><span class="p">.</span><span class="n">JsonConvert</span><span class="p">.</span><span class="nf">SerializeObject</span><span class="p">(</span><span class="n">newValue</span><span class="p">));</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>To test the data layer, update <em>Program.cs</em> with the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">MagPiTracker.Persistence</span><span class="p">;</span>

<span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">"Running The MagPi Tracker..."</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">repository</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">JsonMagPiRepository</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">lastSavedIssueNumber</span> <span class="p">=</span> <span class="k">await</span> <span class="n">repository</span><span class="p">.</span><span class="nf">GetLastSavedIssueNumber</span><span class="p">();</span>
<span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"Last Saved Issue Number: </span><span class="p">{</span><span class="n">lastSavedIssueNumber</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>

<span class="c1">// Test write and read back</span>
<span class="k">await</span> <span class="n">repository</span><span class="p">.</span><span class="nf">SaveLastSavedIssueNumber</span><span class="p">(</span><span class="m">120</span><span class="p">);</span>
<span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"[Test] Last Saved Issue Number: </span><span class="p">{</span><span class="k">await</span> <span class="n">repository</span><span class="p">.</span><span class="nf">GetLastSavedIssueNumber</span><span class="p">()}</span><span class="s">"</span><span class="p">);</span>
</code></pre></div></div>

<p>The output should look like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Running The MagPi Tracker...
Last Saved Issue Number: 0
Test Last Saved Issue Number: 120

</code></pre></div></div>

<h3 id="issue-checker">Issue Checker</h3>

<p>The next task is to implement the service to check the latest available issue on The MagPi Magazine page. What we need to get out of this service is:</p>

<ul>
  <li>The latest issue number</li>
  <li>The link to the PDF</li>
  <li>The link to the cover image (to make the notification email prettier)</li>
</ul>

<p>So create an interface called <em>IMagPiService.cs</em> that looks like this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">MagPiTracker.MagPi</span><span class="p">;</span>

<span class="k">public</span> <span class="k">interface</span> <span class="nc">IMagPiService</span>
<span class="p">{</span>
    <span class="n">Task</span><span class="p">&lt;</span><span class="kt">int</span><span class="p">&gt;</span> <span class="nf">GetLatestIssueNumber</span><span class="p">();</span>
    <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">GetIssuePdfUrl</span><span class="p">(</span><span class="kt">int</span> <span class="n">issueNumber</span><span class="p">);</span>
    <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">GetLatestIssueCoverUrl</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Create a class called MagPiService that implements the interface, and that looks like this initially:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">MagPiTracker.MagPi</span><span class="p">;</span>

<span class="k">public</span> <span class="k">class</span> <span class="nc">MagPiService</span> <span class="p">:</span> <span class="n">IMagPiService</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">int</span><span class="p">&gt;</span> <span class="nf">GetLatestIssueNumber</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">NotImplementedException</span><span class="p">();</span>
    <span class="p">}</span>

    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">GetIssuePdfUrl</span><span class="p">(</span><span class="kt">int</span> <span class="n">issueNumber</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">NotImplementedException</span><span class="p">();</span>
    <span class="p">}</span>

    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">GetLatestIssueCoverUrl</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">NotImplementedException</span><span class="p">();</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now, let’s focus on getting the latest issue number. The easiest way to find the latest issue number is by going to the <a href="https://magpi.raspberrypi.com/issues/">issues page</a>, which looks like this at the time of this writing:</p>

<p><img src="/images/vpblogimg/2025/10/magpi-tracker/02-magpi-issues-page.png" alt="The MagPI Magazine issues page showing the latest issue" /></p>

<p>We’re going to utilize some web scraping to get the job done. In this project, I used a library called <a href="https://anglesharp.github.io/">AngleSharp</a> to achieve this. Run the following command to add it to your project:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package AngleSharp
</code></pre></div></div>

<p>Then, update your <em>GetLatestIssueNumber</em> implementation as shown below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">AngleSharp</span><span class="p">;</span>

<span class="k">namespace</span> <span class="nn">MagPiTracker.MagPi</span><span class="p">;</span>

<span class="k">public</span> <span class="k">class</span> <span class="nc">MagPiService</span> <span class="p">:</span> <span class="n">IMagPiService</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">const</span> <span class="kt">string</span> <span class="n">MAGPI_ROOT_URL</span> <span class="p">=</span> <span class="s">"https://magpi.raspberrypi.com"</span><span class="p">;</span>
    
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">int</span><span class="p">&gt;</span> <span class="nf">GetLatestIssueNumber</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">config</span> <span class="p">=</span> <span class="n">AngleSharp</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="n">Default</span><span class="p">.</span><span class="nf">WithDefaultLoader</span><span class="p">();</span>
        <span class="kt">var</span> <span class="n">context</span> <span class="p">=</span> <span class="n">BrowsingContext</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="n">config</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">document</span> <span class="p">=</span> <span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="nf">OpenAsync</span><span class="p">(</span><span class="s">$"</span><span class="p">{</span><span class="n">MAGPI_ROOT_URL</span><span class="p">}</span><span class="s">/issues/"</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">latestCoverLinkSelector</span> <span class="p">=</span> <span class="s">".c-latest-issue &gt; .c-latest-issue__cover &gt; a"</span><span class="p">;</span>
        <span class="kt">var</span> <span class="n">latestCoverLink</span> <span class="p">=</span> <span class="n">document</span><span class="p">.</span><span class="nf">QuerySelector</span><span class="p">(</span><span class="n">latestCoverLinkSelector</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">rawLink</span> <span class="p">=</span> <span class="n">latestCoverLink</span><span class="p">.</span><span class="n">Attributes</span><span class="p">.</span><span class="nf">GetNamedItem</span><span class="p">(</span><span class="s">"href"</span><span class="p">).</span><span class="n">Value</span><span class="p">;</span>
        <span class="k">return</span> <span class="kt">int</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="n">rawLink</span><span class="p">.</span><span class="nf">Substring</span><span class="p">(</span><span class="n">rawLink</span><span class="p">.</span><span class="nf">LastIndexOf</span><span class="p">(</span><span class="sc">'/'</span><span class="p">)</span> <span class="p">+</span> <span class="m">1</span><span class="p">));</span>
    <span class="p">}</span>

    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">GetIssuePdfUrl</span><span class="p">(</span><span class="kt">int</span> <span class="n">issueNumber</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">NotImplementedException</span><span class="p">();</span>
    <span class="p">}</span>

    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">GetLatestIssueCoverUrl</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">NotImplementedException</span><span class="p">();</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>To put this to the test, update your <em>Program.cs</em> as below and run the application:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">MagPiTracker.MagPi</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">MagPiTracker.Persistence</span><span class="p">;</span>

<span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">"Running The MagPi Tracker..."</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">repository</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">JsonMagPiRepository</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">magpiService</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">MagPiService</span><span class="p">();</span>

<span class="kt">var</span> <span class="n">lastSavedIssueNumber</span> <span class="p">=</span> <span class="k">await</span> <span class="n">repository</span><span class="p">.</span><span class="nf">GetLastSavedIssueNumber</span><span class="p">();</span>
<span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"Last Saved Issue Number: </span><span class="p">{</span><span class="n">lastSavedIssueNumber</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">latestIssueNumber</span> <span class="p">=</span> <span class="k">await</span> <span class="n">magpiService</span><span class="p">.</span><span class="nf">GetLatestIssueNumber</span><span class="p">();</span>
<span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"Latest Issue Number: </span><span class="p">{</span><span class="n">latestIssueNumber</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
</code></pre></div></div>

<p>You should see an output similar to this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Running The MagPi Tracker...
Last Saved Issue Number: 120
Latest Issue Number: 121
</code></pre></div></div>

<p>Your latest issue will probably be different depending on when you are running it.</p>

<h3 id="download-the-pdf">Download the PDF</h3>

<p>The next challenge is to find the direct link to the PDF. If you click the Download Free PDF link, the page does not start the download automatically. Instead, you land on a donation page that looks like this:</p>

<p><img src="/images/vpblogimg/2025/10/magpi-tracker/03-magpi-donation-page.png" alt="The MagPi Magazine donation page" /></p>

<p><strong>I’d strongly recommend everybody to consider donating. This is a great magazine with professional quality, and it’s full of valuable knowledge about everything Raspberry Pi.</strong></p>

<p>Since they are allowing free downloads and Raspberry Pi is mostly a favourite among maker-community, I’m hoping they wouldn’t mind this little project.</p>

<p>If you click on the “<em>No thanks, take me to the free PDF</em>” link, the PDF download starts automatically. This is actually done by a redirect that contains an iframe with the src property set to the URL of the PDF. So to download the PDF, you need to parse the URL.</p>

<p>Create a new project directory called <em>Downloader</em> and add a new interface named IDownloadService.cs that looks like this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">MagPiTracker.Downloader</span><span class="p">;</span>

<span class="k">public</span> <span class="k">interface</span> <span class="nc">IDownloadService</span>
<span class="p">{</span>
    <span class="n">Task</span> <span class="nf">DownloadFile</span><span class="p">(</span><span class="kt">string</span> <span class="n">url</span><span class="p">,</span> <span class="kt">string</span> <span class="n">localPath</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>As you can tell from the method name and its arguments, this service is going to download the file at the given URL and save it to the local file system.</p>

<p>For the actual implementation, create a class called DownloadService implementing the interface and update the code with this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">MagPiTracker.Downloader</span><span class="p">;</span>

<span class="k">public</span> <span class="k">class</span> <span class="nc">DownloadService</span> <span class="p">:</span> <span class="n">IDownloadService</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">DownloadFile</span><span class="p">(</span><span class="kt">string</span> <span class="n">url</span><span class="p">,</span> <span class="kt">string</span> <span class="n">localPath</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">using</span> <span class="nn">HttpClient</span> <span class="n">client</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">HttpClient</span><span class="p">();</span> <span class="c1">// use HttpClient factory in production</span>
        <span class="k">using</span> <span class="nn">HttpResponseMessage</span> <span class="n">response</span> <span class="p">=</span> <span class="k">await</span> <span class="n">client</span><span class="p">.</span><span class="nf">GetAsync</span><span class="p">(</span><span class="n">url</span><span class="p">);</span>
        <span class="k">using</span> <span class="nn">Stream</span> <span class="n">downloadedFileStream</span> <span class="p">=</span> <span class="k">await</span> <span class="n">response</span><span class="p">.</span><span class="n">Content</span><span class="p">.</span><span class="nf">ReadAsStreamAsync</span><span class="p">();</span>
        
        <span class="k">using</span> <span class="p">(</span><span class="kt">var</span> <span class="n">localFileStream</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">FileStream</span><span class="p">(</span><span class="n">localPath</span><span class="p">,</span> <span class="n">FileMode</span><span class="p">.</span><span class="n">Create</span><span class="p">,</span> <span class="n">FileAccess</span><span class="p">.</span><span class="n">Write</span><span class="p">))</span>
        <span class="p">{</span>
            <span class="k">await</span> <span class="n">downloadedFileStream</span><span class="p">.</span><span class="nf">CopyToAsync</span><span class="p">(</span><span class="n">localFileStream</span><span class="p">);</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>To test these changes, update the <em>Program.cs</em> file with the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">MagPiTracker.Downloader</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">MagPiTracker.MagPi</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">MagPiTracker.Persistence</span><span class="p">;</span>

<span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">"Running The MagPi Tracker..."</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">repository</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">JsonMagPiRepository</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">magpiService</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">MagPiService</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">downloadService</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">DownloadService</span><span class="p">();</span>

<span class="kt">var</span> <span class="n">lastSavedIssueNumber</span> <span class="p">=</span> <span class="k">await</span> <span class="n">repository</span><span class="p">.</span><span class="nf">GetLastSavedIssueNumber</span><span class="p">();</span>
<span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"Last Saved Issue Number: </span><span class="p">{</span><span class="n">lastSavedIssueNumber</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">latestIssueNumber</span> <span class="p">=</span> <span class="k">await</span> <span class="n">magpiService</span><span class="p">.</span><span class="nf">GetLatestIssueNumber</span><span class="p">();</span>
<span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"Latest Issue Number: </span><span class="p">{</span><span class="n">latestIssueNumber</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>

<span class="k">if</span> <span class="p">(</span><span class="n">latestIssueNumber</span> <span class="p">&gt;</span> <span class="n">lastSavedIssueNumber</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">pdfUrl</span> <span class="p">=</span> <span class="k">await</span> <span class="n">magpiService</span><span class="p">.</span><span class="nf">GetIssuePdfUrl</span><span class="p">(</span><span class="n">latestIssueNumber</span><span class="p">);</span>
    <span class="kt">var</span> <span class="n">localPath</span> <span class="p">=</span> <span class="s">$"TheMagPiMagazine_</span><span class="p">{</span><span class="n">latestIssueNumber</span><span class="p">.</span><span class="nf">ToString</span><span class="p">().</span><span class="nf">PadLeft</span><span class="p">(</span><span class="m">3</span><span class="p">,</span> <span class="sc">'0'</span><span class="p">)}</span><span class="s">.pdf"</span><span class="p">;</span>
    <span class="k">await</span> <span class="n">downloadService</span><span class="p">.</span><span class="nf">DownloadFile</span><span class="p">(</span><span class="n">pdfUrl</span><span class="p">,</span> <span class="n">localPath</span><span class="p">);</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"Latest Issue PDF has been saved to </span><span class="p">{</span><span class="n">localPath</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
<span class="p">}</span>
<span class="k">else</span>
<span class="p">{</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"No new issue found. Exiting."</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The final version of the application checks its database and compares it to the latest issue. If the latest one is newer, then it downloads the PDF. Run the application, and you should see the new PDF downloaded to your local machine. Your output should look like this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Running</span> <span class="n">The</span> <span class="n">MagPi</span> <span class="n">Tracker</span><span class="p">...</span>
<span class="n">Last</span> <span class="n">Saved</span> <span class="n">Issue</span> <span class="n">Number</span><span class="p">:</span> <span class="m">120</span>
<span class="n">Latest</span> <span class="n">Issue</span> <span class="n">Number</span><span class="p">:</span> <span class="m">121</span>
<span class="n">Latest</span> <span class="n">Issue</span> <span class="n">PDF</span> <span class="n">has</span> <span class="n">been</span> <span class="n">saved</span> <span class="n">to</span> <span class="n">TheMagPiMagazine_121</span><span class="p">.</span><span class="n">pdf</span>
</code></pre></div></div>

<h3 id="importing-to-calibre">Importing to Calibre</h3>

<p>The next step is to import this file into Calibre. An easy way to wrap external CLIs is the <a href="https://github.com/Tyrrrz/CliWrap">CliWrap</a> library. Add it to your project via NuGet by running the command below:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package CliWrap
</code></pre></div></div>

<p>Create a new folder called <em>Calibre</em> and a new interface called <em>ICalibreService.cs</em> under it.</p>

<p>Update the interface with this code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">MagPiTracker.Calibre</span><span class="p">;</span>

<span class="k">public</span> <span class="k">interface</span> <span class="nc">ICalibreService</span>
<span class="p">{</span>
    <span class="n">Task</span> <span class="nf">ImportMagPiMagazine</span><span class="p">(</span><span class="kt">int</span> <span class="n">issueNumber</span><span class="p">,</span> <span class="kt">string</span> <span class="n">pdfPath</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This method is going to be tailored for The MagPi Magazine. The MagPi-related information can be stripped out of the method and put somewhere else, like a config file, but since I’m not aiming to make this a generic downloader, for the time being, it should do the job.</p>

<p>Create the implementation class named CalibreService and implement the interface like this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">System.Text</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">CliWrap</span><span class="p">;</span>

<span class="k">namespace</span> <span class="nn">MagPiTracker.Calibre</span><span class="p">;</span>

<span class="k">public</span> <span class="k">class</span> <span class="nc">CalibreService</span> <span class="p">:</span> <span class="n">ICalibreService</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">const</span> <span class="kt">string</span> <span class="n">LIBRARY_PATH</span> <span class="p">=</span> <span class="s">"{PATH TO YOUR CALIBRE LIBRARY}"</span><span class="p">;</span>
    
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">ImportMagPiMagazine</span><span class="p">(</span><span class="kt">int</span> <span class="n">issueNumber</span><span class="p">,</span> <span class="kt">string</span> <span class="n">pdfPath</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">issueTitle</span> <span class="p">=</span> <span class="s">$"The MagPi Issue </span><span class="p">{</span><span class="n">issueNumber</span><span class="p">.</span><span class="nf">ToString</span><span class="p">().</span><span class="nf">PadLeft</span><span class="p">(</span><span class="m">3</span><span class="p">,</span> <span class="sc">'0'</span><span class="p">)}</span><span class="s">"</span><span class="p">;</span>
        <span class="kt">var</span> <span class="n">authors</span> <span class="p">=</span> <span class="s">"Raspberry Pi Press"</span><span class="p">;</span>
        <span class="kt">var</span> <span class="n">series</span> <span class="p">=</span> <span class="s">"The MagPi Magazine"</span><span class="p">;</span>
        
        <span class="kt">var</span> <span class="n">stdOutBuffer</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">StringBuilder</span><span class="p">();</span>
        <span class="kt">var</span> <span class="n">stdErrBuffer</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">StringBuilder</span><span class="p">();</span>
        
        <span class="k">await</span> <span class="n">Cli</span><span class="p">.</span><span class="nf">Wrap</span><span class="p">(</span><span class="s">"/Applications/calibre.app/Contents/MacOS/calibredb"</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">WithArguments</span><span class="p">(</span><span class="s">$"add --title \"</span><span class="p">{</span><span class="n">issueTitle</span><span class="p">}</span><span class="s">\" --with-library \"</span><span class="p">{</span><span class="n">LIBRARY_PATH</span><span class="p">}</span><span class="s">\" --authors \"</span><span class="p">{</span><span class="n">authors</span><span class="p">}</span><span class="s">\" --series \"</span><span class="p">{</span><span class="n">series</span><span class="p">}</span><span class="s">\" \"</span><span class="p">{</span><span class="n">pdfPath</span><span class="p">}</span><span class="s">\""</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">WithStandardOutputPipe</span><span class="p">(</span><span class="n">PipeTarget</span><span class="p">.</span><span class="nf">ToStringBuilder</span><span class="p">(</span><span class="n">stdOutBuffer</span><span class="p">))</span>
            <span class="p">.</span><span class="nf">WithStandardErrorPipe</span><span class="p">(</span><span class="n">PipeTarget</span><span class="p">.</span><span class="nf">ToStringBuilder</span><span class="p">(</span><span class="n">stdErrBuffer</span><span class="p">))</span>
            <span class="p">.</span><span class="nf">ExecuteAsync</span><span class="p">();</span>
        
        <span class="kt">var</span> <span class="n">stdOut</span> <span class="p">=</span> <span class="n">stdOutBuffer</span><span class="p">.</span><span class="nf">ToString</span><span class="p">();</span>
        <span class="kt">var</span> <span class="n">stdErr</span> <span class="p">=</span> <span class="n">stdErrBuffer</span><span class="p">.</span><span class="nf">ToString</span><span class="p">();</span>
        
        <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="n">stdOut</span><span class="p">);</span>
        <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="n">stdErr</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Make sure to update LIBRARY_PATH. Also, update the application path depending on your operating system.</p>

<p>Then, update the Program.cs like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>using MagPiTracker.Calibre;
using MagPiTracker.Downloader;
using MagPiTracker.MagPi;
using MagPiTracker.Notifications;
using MagPiTracker.Persistence;

Console.WriteLine("Running The MagPi Tracker...");

var repository = new JsonMagPiRepository();
var magpiService = new MagPiService();

var lastSavedIssueNumber = await repository.GetLastSavedIssueNumber();
Console.WriteLine($"Last Saved Issue Number: {lastSavedIssueNumber}");

var latestIssueNumber = await magpiService.GetLatestIssueNumber();
Console.WriteLine($"Latest Issue Number: {latestIssueNumber}");

if (latestIssueNumber &gt; lastSavedIssueNumber)
{
    var pdfUrl = await magpiService.GetIssuePdfUrl(latestIssueNumber);
    var localPath = $"TheMagPiMagazine_{latestIssueNumber.ToString().PadLeft(3, '0')}.pdf";
 
    var downloadService = new DownloadService();
    await downloadService.DownloadFile(pdfUrl, localPath);
    Console.WriteLine($"Latest Issue PDF has been saved to {localPath}");
    
    var calibreService = new CalibreService();
    await calibreService.ImportMagPiMagazine(latestIssueNumber, new FileInfo(localPath).FullName);
    Console.WriteLine($"Latest Issue has been imported into Calibre");
}
else
{
    Console.WriteLine($"No new issue found. Exiting.");
}
</code></pre></div></div>

<p>Run the application, and you should see an output like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Running The MagPi Tracker...
Last Saved Issue Number: 120
Latest Issue Number: 121
Latest Issue PDF has been saved to TheMagPiMagazine_121.pdf
    /Users/.../scheduled-magpi-magazine-tracker/src/TheMagPiMagazine_121.pdf

The following books were not added as they already exist in the database (see --duplicates option or --automerge option):
  The MagPi Issue 121

Latest Issue has been imported into Calibre

</code></pre></div></div>

<p>You can go ahead and open your Calibre application, and you should see the newly imported issue in your library:</p>

<p><img src="/images/vpblogimg/2025/10/magpi-tracker/04-magpi-in-calibre.png" alt="The latest MagPi issue shown in Calibre" /></p>

<h3 id="cover-image-url">Cover Image URL</h3>

<p>In the previous section, we left out implementing the third method. This is not strictly necessary, but having the cover image would make your notification email look nicer. Also, from a practical point of view, if you’re not interested in the topics covered in that issue, you may delay looking at that issue.</p>

<p>To get the cover URL, revisit MagPiService class and update the <em>GetLatestIssueCoverUrl</em> method’s implementation as below:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="nf">GetLatestIssueCoverUrl</span><span class="p">()</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">config</span> <span class="p">=</span> <span class="n">AngleSharp</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="n">Default</span><span class="p">.</span><span class="nf">WithDefaultLoader</span><span class="p">();</span>
    <span class="kt">var</span> <span class="n">context</span> <span class="p">=</span> <span class="n">BrowsingContext</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="n">config</span><span class="p">);</span>
    <span class="kt">var</span> <span class="n">document</span> <span class="p">=</span> <span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="nf">OpenAsync</span><span class="p">(</span><span class="s">$"</span><span class="p">{</span><span class="n">MAGPI_ROOT_URL</span><span class="p">}</span><span class="s">/issues/"</span><span class="p">);</span>
    <span class="kt">var</span> <span class="n">latestCoverImageSelector</span> <span class="p">=</span> <span class="s">".c-latest-issue &gt; .c-latest-issue__cover &gt; a &gt; img"</span><span class="p">;</span>
    <span class="kt">var</span> <span class="n">latestCoverImage</span> <span class="p">=</span> <span class="n">document</span><span class="p">.</span><span class="nf">QuerySelector</span><span class="p">(</span><span class="n">latestCoverImageSelector</span><span class="p">);</span>
    <span class="kt">var</span> <span class="n">latestCoverImageUrl</span> <span class="p">=</span> <span class="n">latestCoverImage</span><span class="p">.</span><span class="n">Attributes</span><span class="p">.</span><span class="nf">GetNamedItem</span><span class="p">(</span><span class="s">"src"</span><span class="p">).</span><span class="n">Value</span><span class="p">;</span>
    
    <span class="k">return</span> <span class="n">latestCoverImageUrl</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This will come in handy in the next section.</p>

<h3 id="notifications">Notifications</h3>

<p>It would be nice to know when a new issue is imported into your library, so the next step is to add a notification mechanism to the application. In this example, I will use email notifications as that’s the cheapest and simplest method. I will use SendGrid as my SMTP provider.</p>

<p>To store the API key, initialize <strong>dotnet user-secrets</strong> and create a new secret by running the following commands:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet user-secrets init
dotnet user-secrets <span class="nb">set </span>SendGrid:ApiKey <span class="o">{</span>YOUR API KEY<span class="o">}</span>
</code></pre></div></div>

<p>In the project, create a new project directory called Notifications and a new interface called <em>INewIssueNotificationService.cs</em> with the following code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">namespace</span> <span class="nn">MagPiTracker.Notifications</span><span class="p">;</span>

<span class="k">public</span> <span class="k">interface</span> <span class="nc">INewIssueNotificationService</span>
<span class="p">{</span>
    <span class="n">Task</span> <span class="nf">SendNewIssueNotification</span><span class="p">(</span><span class="kt">int</span> <span class="n">issueNumber</span><span class="p">,</span> <span class="kt">string</span> <span class="n">coverUrl</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Before implementing the class, add SendGrid SDK by running:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package SendGrid
</code></pre></div></div>

<p>Now add a new class called <em>EmailNotificationService.cs</em> and update its code with this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">System.Reflection</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.Extensions.Configuration</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">SendGrid</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">SendGrid.Helpers.Mail</span><span class="p">;</span>

<span class="k">namespace</span> <span class="nn">MagPiTracker.Notifications</span><span class="p">;</span>

<span class="k">public</span> <span class="k">class</span> <span class="nc">EmailNotificationService</span> <span class="p">:</span> <span class="n">INewIssueNotificationService</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">SendNewIssueNotification</span><span class="p">(</span><span class="kt">int</span> <span class="n">issueNumber</span><span class="p">,</span> <span class="kt">string</span> <span class="n">coverUrl</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">IConfiguration</span> <span class="n">config</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">ConfigurationBuilder</span><span class="p">()</span>
            <span class="p">.</span><span class="nf">AddUserSecrets</span><span class="p">(</span><span class="n">Assembly</span><span class="p">.</span><span class="nf">GetExecutingAssembly</span><span class="p">(),</span> <span class="n">optional</span><span class="p">:</span> <span class="k">true</span><span class="p">,</span> <span class="n">reloadOnChange</span><span class="p">:</span> <span class="k">false</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">Build</span><span class="p">();</span>
        
        <span class="kt">var</span> <span class="n">sendGridClient</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">SendGridClient</span><span class="p">(</span><span class="n">apiKey</span><span class="p">:</span> <span class="n">config</span><span class="p">[</span><span class="s">"SendGrid:ApiKey"</span><span class="p">]);</span>

        <span class="kt">var</span> <span class="k">from</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">EmailAddress</span><span class="p">(</span><span class="s">"{YOUR VERIFIED SENDER EMAIL ADDRESS}"</span><span class="p">,</span> <span class="s">"The MagPi Magazine Issue Checker"</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">to</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">EmailAddress</span><span class="p">(</span><span class="s">"{YOUR RECIPIENT EMAIL ADDRESS}"</span><span class="p">,</span> <span class="s">"{YOUR DISPLAY NAME}"</span><span class="p">);</span>

        <span class="kt">var</span> <span class="n">htmlContent</span> <span class="p">=</span> <span class="k">await</span> <span class="n">File</span><span class="p">.</span><span class="nf">ReadAllTextAsync</span><span class="p">(</span><span class="s">"./notifications/email-template.html"</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">htmlWithData</span> <span class="p">=</span> <span class="n">htmlContent</span><span class="p">.</span><span class="nf">Replace</span><span class="p">(</span><span class="s">"%{COVER_URL}"</span><span class="p">,</span> <span class="n">coverUrl</span><span class="p">);</span>
        
        <span class="kt">var</span> <span class="n">msg</span> <span class="p">=</span> <span class="n">MailHelper</span><span class="p">.</span><span class="nf">CreateSingleEmail</span><span class="p">(</span><span class="k">from</span><span class="p">,</span> <span class="n">to</span><span class="p">,</span> <span class="s">"The MagPi Magazine New Issue"</span><span class="p">,</span> <span class="n">htmlWithData</span><span class="p">,</span> <span class="n">htmlWithData</span><span class="p">);</span>
        <span class="k">await</span> <span class="n">sendGridClient</span><span class="p">.</span><span class="nf">SendEmailAsync</span><span class="p">(</span><span class="n">msg</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This code reads the API key from user secrets so that it’s never accidentally pushed to source control. Also, it reads the email template from an HTML file. Create a new file named email-template.html, and set it to be copied to the output always (as you did with db.json) and update its contents as shown below:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;!DOCTYPE html&gt;</span>
<span class="nt">&lt;html</span> <span class="na">lang=</span><span class="s">"en"</span><span class="nt">&gt;</span>
<span class="nt">&lt;head&gt;</span>
    <span class="nt">&lt;meta</span> <span class="na">charset=</span><span class="s">"UTF-8"</span><span class="nt">&gt;</span>
<span class="nt">&lt;/head&gt;</span>
<span class="nt">&lt;body&gt;</span>
<span class="nt">&lt;h1&gt;</span>New MagPi Magazine is out!<span class="nt">&lt;/h1&gt;</span>
<span class="nt">&lt;p&gt;</span>
    <span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"%{COVER_URL}"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;/p&gt;</span>
<span class="nt">&lt;/body&gt;</span>
<span class="nt">&lt;/html&gt;</span>
</code></pre></div></div>

<p>There are better ways for variable replacement (using a templating engine such as Handlebars, Razor etc.), but to keep things simple, I just put a placeholder and replaced the string. Update the Program.cs to reflect the latest changes and test:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">MagPiTracker.Calibre</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">MagPiTracker.Downloader</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">MagPiTracker.MagPi</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">MagPiTracker.Notifications</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">MagPiTracker.Persistence</span><span class="p">;</span>

<span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">"Running The MagPi Tracker..."</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">repository</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">JsonMagPiRepository</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">magpiService</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">MagPiService</span><span class="p">();</span>

<span class="kt">var</span> <span class="n">lastSavedIssueNumber</span> <span class="p">=</span> <span class="k">await</span> <span class="n">repository</span><span class="p">.</span><span class="nf">GetLastSavedIssueNumber</span><span class="p">();</span>
<span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"Last Saved Issue Number: </span><span class="p">{</span><span class="n">lastSavedIssueNumber</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">latestIssueNumber</span> <span class="p">=</span> <span class="k">await</span> <span class="n">magpiService</span><span class="p">.</span><span class="nf">GetLatestIssueNumber</span><span class="p">();</span>
<span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"Latest Issue Number: </span><span class="p">{</span><span class="n">latestIssueNumber</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>

<span class="k">if</span> <span class="p">(</span><span class="n">latestIssueNumber</span> <span class="p">&gt;</span> <span class="n">lastSavedIssueNumber</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">pdfUrl</span> <span class="p">=</span> <span class="k">await</span> <span class="n">magpiService</span><span class="p">.</span><span class="nf">GetIssuePdfUrl</span><span class="p">(</span><span class="n">latestIssueNumber</span><span class="p">);</span>
    <span class="kt">var</span> <span class="n">localPath</span> <span class="p">=</span> <span class="s">$"TheMagPiMagazine_</span><span class="p">{</span><span class="n">latestIssueNumber</span><span class="p">.</span><span class="nf">ToString</span><span class="p">().</span><span class="nf">PadLeft</span><span class="p">(</span><span class="m">3</span><span class="p">,</span> <span class="sc">'0'</span><span class="p">)}</span><span class="s">.pdf"</span><span class="p">;</span>
 
    <span class="kt">var</span> <span class="n">downloadService</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">DownloadService</span><span class="p">();</span>
    <span class="k">await</span> <span class="n">downloadService</span><span class="p">.</span><span class="nf">DownloadFile</span><span class="p">(</span><span class="n">pdfUrl</span><span class="p">,</span> <span class="n">localPath</span><span class="p">);</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"Latest Issue PDF has been saved to </span><span class="p">{</span><span class="n">localPath</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
    
    <span class="kt">var</span> <span class="n">calibreService</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">CalibreService</span><span class="p">();</span>
    <span class="k">await</span> <span class="n">calibreService</span><span class="p">.</span><span class="nf">ImportMagPiMagazine</span><span class="p">(</span><span class="n">latestIssueNumber</span><span class="p">,</span> <span class="k">new</span> <span class="nf">FileInfo</span><span class="p">(</span><span class="n">localPath</span><span class="p">).</span><span class="n">FullName</span><span class="p">);</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"Latest Issue has been imported into Calibre"</span><span class="p">);</span>

    <span class="kt">var</span> <span class="n">latestIssueCoverUrl</span> <span class="p">=</span> <span class="k">await</span> <span class="n">magpiService</span><span class="p">.</span><span class="nf">GetLatestIssueCoverUrl</span><span class="p">();</span>
    <span class="kt">var</span> <span class="n">notificationService</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">EmailNotificationService</span><span class="p">();</span>
    <span class="k">await</span> <span class="n">notificationService</span><span class="p">.</span><span class="nf">SendNewIssueNotification</span><span class="p">(</span><span class="n">latestIssueNumber</span><span class="p">,</span> <span class="n">latestIssueCoverUrl</span><span class="p">);</span>

    <span class="k">await</span> <span class="n">repository</span><span class="p">.</span><span class="nf">SaveLastSavedIssueNumber</span><span class="p">(</span><span class="n">latestIssueNumber</span><span class="p">);</span>
    <span class="n">File</span><span class="p">.</span><span class="nf">Delete</span><span class="p">(</span><span class="n">localPath</span><span class="p">);</span>
<span class="p">}</span>
<span class="k">else</span>
<span class="p">{</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"No new issue found. Exiting."</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>In addition to the previous tasks, you are now sending the notification email. Once everything is done successfully, the code updates the local JSON file with the latest issue number so that it doesn’t download the same issue over and over again. Also, it deletes the local PDF to keep things nice and tidy.</p>

<p>Run the application, and you should receive a notification that looks like this:</p>

<p><img src="/images/vpblogimg/2025/10/magpi-tracker/05-notification-email.png" alt="Screenshot of the final notification email showing MagPi cover" /></p>

<p>You can add more stuff like the link to the PDF, issue number etc., but just to ping myself this much information is enough for me.</p>

<h3 id="scheduling">Scheduling</h3>

<p>Let’s bring this home by scheduling the application so that it does its thing in an automated fashion.</p>

<p>As mentioned before, on Windows, I’d recommend using the built-in Task Scheduler. On macOS/Linux systems, crontab does the job.</p>

<p>Firstly, build your application and place the deployment package wherever you want to run the application. To edit cron jobs, run</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>crontab -e
</code></pre></div></div>

<p>I will run the application every Friday at 5 AM and will use this cron expression: 0 5 * * 5</p>

<p>To find where the dotnet executable is located, you can use the which command:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>which dotnet
</code></pre></div></div>

<p>Also, the cron job will be run in a different working directory. To avoid path issues, it’s best to change to our application directory before running it. So the cron job looks like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0 5 * * 5 cd /Users/.../Deployment/MagPiTracker &amp;&amp; /usr/local/share/dotnet/dotnet MagPiTracker.dll
</code></pre></div></div>

<p>Crontab uses the following syntax, and you can customize your schedule based on this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>* * * * * command
* - minute (0-59)
* - hour (0-23)
* - day of the month (1-31)
* - month (1-12)
* - day of the week (0-6, 0 is Sunday)

</code></pre></div></div>

<h3 id="why-no-docker">Why No Docker?</h3>

<p>Normally I try to run everything in Docker containers. In this project, I chose to run the application on bare metal. The reason for this is to be able to import the PDFs into my Calibre library, which is also running on bare metal. If I were to run this application in Docker, I wouldn’t be able to run Calibre CLI on the host computer. If I didn’t have this constraint, I would have definitely Dockerized the application.</p>

<h2 id="conclusion">Conclusion</h2>

<p>I hope you enjoyed this little project. As a reminder, please consider donating to the Raspberry Pi Press and use their own mechanism, but if you cannot afford it and since the PDFs are already available out there, you can go ahead and use this project and hopefully learn some new technologies along the way.</p>

]]></content>
  </entry>
  
  
</feed>