Local Webhook Testing Using ngrok to Connect GitHub Actions with Your .NET API

Table Of Contents
Introduction
As developers, we frequently face a common challenge: how do we test webhook-driven workflows that depend on services running locally? More specifically, how do we validate GitHub Actions that need to communicate with APIs hosted on our development machines?
Enter ngrok — a powerful tunneling tool that creates secure tunnels to expose your local services to the internet. In this guide, I’ll walk you through using ngrok to create a bridge between GitHub Actions and your local .NET API endpoints, enabling seamless testing without deploying to a staging environment.
The Local Development Dilemma
Before diving into the solution, let’s understand the problem:
- You’re developing a .NET API locally (typically accessible only via
localhost) - You’ve created GitHub Actions workflows that need to interact with your API
- GitHub’s runners can’t directly access your local development environment
- You want to test the complete workflow without deploying your API to a publicly accessible server
This is where ngrok shines, creating a secure tunnel that makes your local services temporarily accessible from anywhere.
Prerequisites
Before we begin, make sure you have:
- .NET SDK installed
- A GitHub repository with GitHub Actions configured
- ngrok account and CLI tool installed
- Basic familiarity with webhooks and API development
Setting Up Your .NET API
Let’s start with a simple .NET API that we’ll expose via ngrok. For this example, I’ll create a basic webhook receiver that logs incoming GitHub event data.
using Microsoft.AspNetCore.Mvc;using System.Text.Json;var builder = WebApplication.CreateBuilder(args);var app = builder.Build();app.MapPost("/api/github-webhook", async (HttpContext context) =>{// Read the request bodyusing var reader = new StreamReader(context.Request.Body);var requestBody = await reader.ReadToEndAsync();// Parse the JSON payloadvar payload = JsonSerializer.Deserialize<JsonDocument>(requestBody);// Log the event (in a real app, you'd process this based on your requirements)Console.WriteLine($"Received GitHub webhook: {payload?.RootElement.GetProperty("action")}");Console.WriteLine($"Event payload: {requestBody}");// Return a success responsereturn Results.Ok(new { message = "Webhook received successfully" });});app.Run("http://localhost:5000");
Save this as Program.cs in a new .NET project. This creates a simple API with a single endpoint that accepts GitHub webhook payloads.
To run this API locally:
dotnet run
Your API is now running at http://localhost:5000, but it’s not accessible from the internet yet.
Exposing Your API with ngrok
Now, let’s use ngrok to create a public URL for your local API:
- Start ngrok to create a tunnel to your local service
ngrok http 5000
- ngrok provides a public URL that forwards to your local service
You’ll see output similar to this:
Session Status onlineAccount your-account (Plan: Free)Version 3.3.1Region United States (us)Latency 51msWeb Interface http://127.0.0.1:4040Forwarding https://a1b2c3d4e5f6.ngrok.io -> http://localhost:5000
The important part is the Forwarding URL (https://a1b2c3d4e5f6.ngrok.io in this example). This is your temporary public URL that routes to your local API.
Note: Each time you restart ngrok, you’ll get a different URL. For persistent URLs, consider upgrading to a paid ngrok plan.
Configuring GitHub Actions to Use Your ngrok URL
Now we’ll set up a GitHub Action workflow that interacts with your local API via the ngrok tunnel:
name: Test Local API Integrationon:push:branches: [ main ]workflow_dispatch:jobs:test-webhook:runs-on: ubuntu-lateststeps:- name: Checkout codeuses: actions/checkout@v3- name: Send test webhook to local APIrun: |curl -X POST \-H "Content-Type: application/json" \-H "X-GitHub-Event: ping" \-d '{"action": "test", "sender": {"login": "github-actions"}}' \https://a1b2c3d4e5f6.ngrok.io/api/github-webhook
Save this as .github/workflows/test-local-api.yml in your repository.
Important: Replace
https://a1b2c3d4e5f6.ngrok.iowith your actual ngrok URL from the previous step.
Creating a More Robust Setup
The basic example works, but in a real development workflow, you need something more robust. Let’s improve our setup:
1. Making the ngrok URL configurable
Instead of hardcoding the ngrok URL, let’s use GitHub repository secrets:
name: Test Local API Integrationon:push:branches: [ main ]workflow_dispatch:inputs:ngrok_url:description: 'Current ngrok URL (without trailing slash)'required: truejobs:test-webhook:runs-on: ubuntu-lateststeps:- name: Checkout codeuses: actions/checkout@v3- name: Send test webhook to local APIrun: |# Use the input URL if provided, otherwise use the secretWEBHOOK_URL="${{ github.event.inputs.ngrok_url || secrets.NGROK_URL }}"curl -X POST \-H "Content-Type: application/json" \-H "X-GitHub-Event: ping" \-d '{"action": "test", "sender": {"login": "github-actions"}}' \$WEBHOOK_URL/api/github-webhook
This allows you to:
- Store a default ngrok URL as a repository secret (
NGROK_URL) - Override it when manually triggering the workflow
2. Creating a bash script for ngrok management
To make the ngrok setup easier to use, create a start-ngrok.sh script:
#!/bin/bash# Kill any running ngrok processpkill -f ngrok || true# Start ngrok in the backgroundngrok http 5000 > /dev/null &# Wait for ngrok to initializesleep 3# Get the public URLNGROK_URL=$(curl -s http://localhost:4040/api/tunnels | jq -r '.tunnels[0].public_url')echo "====================================================="echo "ngrok tunnel is running!"echo "Public URL: $NGROK_URL"echo "====================================================="echo ""echo "To update your GitHub repository with this URL, run:"echo "gh secret set NGROK_URL --body=\"$NGROK_URL\""echo ""echo "Or use this URL when manually triggering the workflow"echo "====================================================="# Keep the script running to maintain the tunnelwait
Make it executable:
chmod +x start-ngrok.sh
Now you can start ngrok with a simple command and easily update your GitHub secret with the new URL.
Enhanced .NET API with Logging and Security
Let’s improve our .NET API to be more production-ready:
using Microsoft.AspNetCore.Mvc;using System.Text.Json;using System.Security.Cryptography;using System.Text;var builder = WebApplication.CreateBuilder(args);// Add servicesbuilder.Services.AddLogging();var app = builder.Build();// GitHub webhook secretstring webhookSecret = builder.Configuration["GitHub:WebhookSecret"] ?? "your-webhook-secret";app.MapPost("/api/github-webhook", async (HttpContext context, ILogger<Program> logger) =>{try{// Verify signature if X-Hub-Signature-256 is presentif (context.Request.Headers.TryGetValue("X-Hub-Signature-256", out var signatureWithPrefix)){// Buffer the request body so we can read it multiple timescontext.Request.EnableBuffering();using var reader = new StreamReader(context.Request.Body,encoding: Encoding.UTF8,detectEncodingFromByteOrderMarks: false,leaveOpen: true);var requestBody = await reader.ReadToEndAsync();// Rewind the stream for later usecontext.Request.Body.Position = 0;// Verify signatureif (!VerifyGitHubWebhook(requestBody, webhookSecret, signatureWithPrefix)){logger.LogWarning("Invalid webhook signature");return Results.Unauthorized();}}// Read the event typevar eventType = context.Request.Headers["X-GitHub-Event"].ToString();// Read the request body (again)using var bodyReader = new StreamReader(context.Request.Body);var body = await bodyReader.ReadToEndAsync();// Parse the JSON payloadvar payload = JsonSerializer.Deserialize<JsonDocument>(body);// Log the eventlogger.LogInformation("Received GitHub webhook: {EventType}, Action: {Action}",eventType,payload?.RootElement.TryGetProperty("action", out var action) ? action.ToString() : "none");// Here you would normally process the webhook based on the event type// For example, handling push events, pull requests, etc.return Results.Ok(new { message = $"Processed {eventType} webhook successfully" });}catch (Exception ex){logger.LogError(ex, "Error processing webhook");return Results.Problem("Error processing webhook");}});app.Run("http://localhost:5000");// GitHub webhook signature verificationbool VerifyGitHubWebhook(string payload, string secret, string signatureWithPrefix){var secretBytes = Encoding.ASCII.GetBytes(secret);var payloadBytes = Encoding.UTF8.GetBytes(payload);using var hmac = new HMACSHA256(secretBytes);var hash = hmac.ComputeHash(payloadBytes);var expectedSignature = "sha256=" + string.Concat(hash.Select(b => b.ToString("x2")));return SignatureEquals(expectedSignature, signatureWithPrefix);}// Constant-time comparison to prevent timing attacksbool SignatureEquals(string a, string b){if (a.Length != b.Length){return false;}int result = 0;for (int i = 0; i < a.Length; i++){result |= a[i] ^ b[i];}return result == 0;}
This enhanced API adds:
- Proper signature verification for GitHub webhooks
- Better error handling
- Structured logging
- Constant-time signature comparison for security
Testing the Complete Flow
Now let’s test the entire workflow:
Start your .NET API
dotnet runStart ngrok using the script
./start-ngrok.shCopy the generated URL.
Update your GitHub Action Either:
- Update the repository secret:
gh secret set NGROK_URL --body="https://your-ngrok-url" - Or manually trigger the workflow and provide the URL
- Update the repository secret:
Run the GitHub Action
- Push a commit to trigger the workflow, or
- Manually trigger it from the GitHub Actions tab
Check your .NET API logs You should see the incoming webhook from GitHub Actions.
Debugging Tips
When working with ngrok and GitHub Actions, you might encounter some issues. Here are some tips:
ngrok Debugging
Access the ngrok inspection interface Open
http://localhost:4040in your browser to see all requests passing through your ngrok tunnel.Enable detailed logging in ngrok
ngrok http 5000 --log=stdoutVerify connectivity
curl -v https://your-ngrok-url.ngrok.io/api/github-webhook
.NET API Debugging
Enable developer exception page Add
app.UseDeveloperExceptionPage();to your .NET API to see detailed error information.Check request headers Log all incoming headers to understand what GitHub is sending:
foreach (var header in context.Request.Headers){Console.WriteLine($"{header.Key}: {header.Value}");}Test locally Send test webhooks directly to your API to verify it works correctly:
curl -X POST \-H "Content-Type: application/json" \-H "X-GitHub-Event: ping" \-d '{"action":"test"}' \http://localhost:5000/api/github-webhook
Security Considerations
When exposing your local services with ngrok, consider these security practices:
Use webhook secrets Always verify webhook signatures using a shared secret.
Limit exposed endpoints Only expose the specific endpoints needed for testing.
Use ngrok’s IP restrictions With paid plans, you can restrict which IPs can access your ngrok tunnel.
Don’t use production data Use test data in your development environment.
Temporary credentials If your API requires authentication, use temporary credentials for testing.
Advanced: Using a Custom GitHub Action
For more complex scenarios, you might want to create a custom GitHub Action that handles communication with your local API. Here’s a simple example:
name: Custom Webhook Actionon:push:branches: [ main ]workflow_dispatch:inputs:ngrok_url:description: 'Current ngrok URL'required: truejobs:send-webhook:runs-on: ubuntu-lateststeps:- name: Checkout codeuses: actions/checkout@v3- name: Setup .NETuses: actions/setup-dotnet@v3with:dotnet-version: '7.0.x'- name: Build webhook senderrun: |dotnet new console -o WebhookSendercd WebhookSender# Add dependenciesdotnet add package System.CommandLinedotnet add package Newtonsoft.Json# Create the webhook sender programcat << EOF > Program.csusing System.CommandLine;using System.Text;using Newtonsoft.Json;using System.Net.Http.Headers;// Define command-line optionsvar urlOption = new Option<string>("--url", "The webhook URL to send data to");var eventOption = new Option<string>("--event", "The GitHub event type");var dataOption = new Option<string>("--data", "JSON payload to send");var rootCommand = new RootCommand("GitHub webhook sender");rootCommand.AddOption(urlOption);rootCommand.AddOption(eventOption);rootCommand.AddOption(dataOption);rootCommand.SetHandler(async (url, eventType, data) =>{using var httpClient = new HttpClient();// Create request messagevar request = new HttpRequestMessage(HttpMethod.Post, url);request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));request.Headers.Add("X-GitHub-Event", eventType);request.Headers.Add("User-Agent", "GitHub-Actions-Webhook-Sender");// Add the payloadvar content = new StringContent(data, Encoding.UTF8, "application/json");request.Content = content;// Send the requestConsole.WriteLine($"Sending {eventType} webhook to {url}");var response = await httpClient.SendAsync(request);// Process the responsevar responseBody = await response.Content.ReadAsStringAsync();Console.WriteLine($"Response: {(int)response.StatusCode} {response.StatusCode}");Console.WriteLine(responseBody);if (!response.IsSuccessStatusCode){Environment.Exit(1);}}, urlOption, eventOption, dataOption);return rootCommand.Invoke(args);EOF# Build the webhook senderdotnet build- name: Send webhook to local APIrun: |cd WebhookSenderTARGET_URL="${{ github.event.inputs.ngrok_url || secrets.NGROK_URL }}/api/github-webhook"dotnet run -- \--url "$TARGET_URL" \--event "workflow_run" \--data '{"action": "completed","workflow_run": {"id": ${{ github.run_id }},"name": "${{ github.workflow }}","conclusion": "success"},"repository": {"full_name": "${{ github.repository }}"}}'
This action creates a small .NET application that sends a webhook to your local API with customizable event types and payloads.
Real-World Use Cases
Here are some practical scenarios where this ngrok-GitHub Actions integration is valuable:
Testing webhooks during development Validate webhook handlers without deploying.
Debugging complex workflows Diagnose issues in GitHub Actions that interact with your API.
Testing integration between services Verify how your service responds to GitHub events.
Local preview of deployment workflows Test deployment processes without affecting production.
Feature branch testing Test new API features with existing CI/CD pipelines.
Conclusion
Using ngrok to connect GitHub Actions with your local .NET API creates a powerful development workflow that saves time and reduces the need for deployment just for testing. By following the approach outlined in this guide, you can:
- Test webhook integrations locally
- Debug GitHub Actions workflows efficiently
- Validate end-to-end processes during development
- Improve your development cycle time
Remember to consider security implications when exposing local services, and always use webhook signature verification in production environments.
Resources
- ngrok Documentation
- GitHub Webhooks Documentation
- GitHub Actions Documentation
- ASP.NET Core Documentation
Feedback and Questions
Your insights drive us! For any questions, feedback, or thoughts, feel free to connect:
- Email: shady@shadynagy.com
- Twitter: @ShadyNagy_
- LinkedIn: Shady Nagy
- GitHub: ShadyNagy
If you found this guide beneficial, don’t hesitate to share it with your network. Until the next guide, happy coding! 🚀







