DEV Community

Cover image for Your First Azure Function: HTTP Triggers Step-by-Step
Martin Oehlert
Martin Oehlert

Posted on

Your First Azure Function: HTTP Triggers Step-by-Step

Theory doesn't ship features. You learned the why in Part 1—when serverless makes sense, the economics, how it compares to App Service and Container Apps. Now let's build something.

In this second part of the Azure Functions for .NET Developers series, we'll create a project from scratch, understand every line of generated code, and build three HTTP trigger patterns that cover most real-world API scenarios. By the end, you'll have a working HTTP API running locally on your machine. No Azure subscription required.

All code from this article is available in the azure-functions-samples repository.

Prerequisites

You need four tools installed before we start. Here's what to check and where to get them:

Tool Verify Expected
.NET 10 SDK dotnet --version 10.x
Azure Functions Core Tools func --version 4.x
VS Code + Azure Functions extension Latest
Azurite npx azurite --version Any

.NET 10 SDK is required to build and run our function app. It's an LTS release, supported through November 2028. Grab it from dot.net/download if you don't have it.

Azure Functions Core Tools v4 lets you create, run, and debug functions locally. Install via npm, Homebrew, or Chocolatey:

# npm (any OS)
npm install -g azure-functions-core-tools@4

# Homebrew (macOS)
brew tap azure/functions
brew install azure-functions-core-tools@4

# Chocolatey (Windows)
choco install azure-functions-core-tools-4
Enter fullscreen mode Exit fullscreen mode

VS Code with the Azure Functions extension gives you project templates, debugging, and deployment in one place. Visual Studio and Rider work too—use whatever you're comfortable with. The CLI commands in this article work regardless of editor.

Azurite emulates Azure Storage locally. The Functions runtime needs a storage connection even for HTTP triggers (it uses storage internally for features like function key management and, in production, for coordinating across instances). Install it globally or run it on demand:

# Install globally
npm install -g azurite

# Or run on demand without installing
npx azurite
Enter fullscreen mode Exit fullscreen mode

If the verification commands all return version numbers and you have VS Code with the Azure Functions extension installed, you're ready.

Creating Your First Project

With your tools in place, open a terminal and scaffold a new function app:

func init HttpTriggerDemo --worker-runtime dotnet-isolated --target-framework net10.0
cd HttpTriggerDemo
func new --template "HTTP trigger" --name Hello
Enter fullscreen mode Exit fullscreen mode

The first command creates the project. --worker-runtime dotnet-isolated selects the isolated worker model (the modern default—we'll explain what "isolated" means in Part 5). --target-framework net10.0 targets .NET 10.

Once inside the project directory, func new adds an HTTP-triggered function called Hello from a built-in template.

Your project now looks like this:

HttpTriggerDemo/
├── Hello.cs              # Your function code
├── Program.cs            # Application entry point
├── HttpTriggerDemo.csproj # Project file
├── host.json             # Runtime configuration
└── local.settings.json   # Local environment variables
Enter fullscreen mode Exit fullscreen mode

The Project File

Open HttpTriggerDemo.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <AzureFunctionsVersion>v4</AzureFunctionsVersion>
    <OutputType>Exe</OutputType>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.51.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
  </ItemGroup>
</Project>
Enter fullscreen mode Exit fullscreen mode

A few things to note:

  • OutputType: Exe — the isolated worker model runs as a standalone executable, not a class library loaded into the Functions host. This is what gives you full control over the process.
  • FrameworkReference: Microsoft.AspNetCore.App — brings in ASP.NET Core types like HttpRequest and IActionResult. Without this, you'd use the lower-level HttpRequestData API.
  • Extensions.Http.AspNetCore — the bridge that maps ASP.NET Core's HTTP abstractions to the Functions runtime. This is what makes the developer experience feel like writing a regular web API.
  • ImplicitUsings: enable — the compiler automatically includes common using statements (System, System.Collections.Generic, System.Linq, System.Threading.Tasks, and others). That's why our code files don't start with a block of using directives.
  • Nullable: enable — turns on nullable reference types. The compiler warns you when code might dereference null without checking. You'll see this in action when we read query string values—req.Query["name"] returns string?, not string.

The Entry Point

Program.cs is minimal:

using Microsoft.Azure.Functions.Worker.Builder;

var builder = FunctionsApplication.CreateBuilder(args);

builder.ConfigureFunctionsWebApplication();

builder.Build().Run();
Enter fullscreen mode Exit fullscreen mode

ConfigureFunctionsWebApplication() is the key line. It enables ASP.NET Core integration—without it, you'd need to use HttpRequestData and HttpResponseData instead of the familiar HttpRequest and IActionResult. For HTTP-triggered functions, this is almost always what you want.

FunctionsApplication.CreateBuilder(args) is a convenience factory introduced in Worker SDK 2.0. It returns a pre-configured IHostApplicationBuilder with Functions defaults already wired up—logging, JSON serializer options, and middleware. The key using to remember is Microsoft.Azure.Functions.Worker.Builder, which brings the factory method into scope. You may also see an older pattern that starts with new HostBuilder() and calls .ConfigureFunctionsWorkerDefaults()—it still works, but requires more manual setup (for example, explicit ConfigureAppConfiguration() calls to load appsettings.json). func init scaffolds FunctionsApplication.CreateBuilder by default, so stick with it for new projects.

This is also where you'd register services for dependency injection, add middleware, or configure logging. We'll keep it simple for now.

Understanding the Generated Code

With the project structure in place, let's look at the function itself. The func new command created a file called Hello.cs with a complete working function. Before we run it, let's understand what each part does. Open Hello.cs:

public class Hello(ILogger<Hello> logger)
{
    [Function("Hello")]
    public IActionResult Run(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequest req)
    {
        logger.LogInformation("C# HTTP trigger function processed a request.");
        return new OkObjectResult("Welcome to Azure Functions!");
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's break this apart.

public class Hello(ILogger<Hello> logger) uses a C# 12 primary constructor. The ILogger<Hello> parameter is injected by the dependency injection container automatically—no need to write a separate constructor or field assignment. The logger is available throughout the class.

[Function("Hello")] registers this method with the Functions runtime. The string "Hello" becomes the function's name and appears in the URL path: http://localhost:7071/api/Hello. If you rename the class but keep the attribute string the same, the URL stays the same.

[HttpTrigger(AuthorizationLevel.Function, "get", "post")] is where the behavior is defined:

  • AuthorizationLevel.Function means callers need a function-specific API key to invoke this endpoint. Other options are Anonymous (no key required) and Admin (requires the master host key). When running locally, authorization is bypassed regardless of the level you set.
  • "get", "post" lists the allowed HTTP methods. Requests using other methods receive a 405 Method Not Allowed.

HttpRequest req is the full ASP.NET Core request object. Query strings, headers, body, route values—it's all there. If you've written ASP.NET Core controllers or minimal APIs, this is the same type.

IActionResult is the return type. OkObjectResult maps to a 200 response. The runtime serializes the object to JSON (or returns it as plain text for strings) and sends it back to the caller. All the familiar result types work: BadRequestResult, NotFoundResult, CreatedResult, and so on.

Run It

Before we modify anything, let's see this function in action. Start Azurite in a separate terminal (or in the background):

npx azurite --silent --location /tmp/azurite &
Enter fullscreen mode Exit fullscreen mode

Then start the Functions runtime from your project directory:

func start
Enter fullscreen mode Exit fullscreen mode

You should see output listing your function's URL:

Functions:

        Hello: [GET,POST] http://localhost:7071/api/Hello
Enter fullscreen mode Exit fullscreen mode

Test it in another terminal:

curl http://localhost:7071/api/Hello
# → Welcome to Azure Functions!
Enter fullscreen mode Exit fullscreen mode

That's a working HTTP endpoint, running locally, no Azure subscription needed. Keep func start running—we'll build on this function next.

HTTP Trigger Deep Dive

With a running function under your belt, let's build three patterns that cover the majority of real-world HTTP function scenarios. After each change, stop func start with Ctrl+C and restart it to pick up the new code.

Pattern 1: GET with Query String

The simplest pattern—read a value from the URL and return a response:

public class HelloFunction(ILogger<HelloFunction> logger)
{
    [Function("Hello")]
    public IActionResult Run(
        [HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req)
    {
        logger.LogInformation("Hello function triggered");

        string? name = req.Query["name"];
        return new OkObjectResult($"Hello, {name ?? "world"}!");
    }
}
Enter fullscreen mode Exit fullscreen mode

req.Query["name"] reads the name parameter from the query string. If it's missing, the value is null, so we fall back to "world" with the null-coalescing operator. Note the string? — the query collection returns a nullable type, and with <Nullable>enable</Nullable> in the project file, the compiler enforces this.

Test it:

curl "http://localhost:7071/api/Hello?name=Azure"
# → Hello, Azure!

curl "http://localhost:7071/api/Hello"
# → Hello, world!
Enter fullscreen mode Exit fullscreen mode

This pattern works well for simple lookups—search queries, filtering lists, or any GET request where the parameters are short and cacheable.

Pattern 2: POST with JSON Body

For creating or updating resources, you'll typically accept a JSON request body. C# records and ASP.NET Core model binding make this clean:

using FromBodyAttribute = Microsoft.Azure.Functions.Worker.Http.FromBodyAttribute;

public record CreateOrderRequest(string ProductId, int Quantity);

public class OrderFunction(ILogger<OrderFunction> logger)
{
    [Function("CreateOrder")]
    public IActionResult CreateOrder(
        [HttpTrigger(AuthorizationLevel.Function, "post", Route = "orders")] HttpRequest req,
        [FromBody] CreateOrderRequest order)
    {
        logger.LogInformation("Order for {ProductId} x{Quantity}", order.ProductId, order.Quantity);
        return new CreatedResult($"/orders/{Guid.NewGuid()}", order);
    }
}
Enter fullscreen mode Exit fullscreen mode

Several things happening here:

record CreateOrderRequest defines an immutable data type. Records are ideal for request and response DTOs—they give you value equality, a clean ToString(), and immutability out of the box. No need to write a class with properties and a constructor.

[FromBody] CreateOrderRequest order tells the ASP.NET Core model binder to deserialize the JSON request body into a CreateOrderRequest instance. If the JSON doesn't match (missing required fields, wrong types), the runtime returns a 400 Bad Request automatically. One gotcha: with ASP.NET Core integration enabled, FromBody exists in two namespaces—Microsoft.Azure.Functions.Worker.Http and Microsoft.AspNetCore.Mvc. You need the Functions Worker version. The using alias at the top of the file resolves the ambiguity so you can use [FromBody] without a fully qualified name.

Route = "orders" overrides the default route. Without it, the URL would be /api/CreateOrder (derived from the function name). With it, the URL becomes /api/orders—cleaner and more RESTful.

CreatedResult returns HTTP 201 with a Location header pointing to the new resource. Standard REST semantics.

Test it:

curl -X POST http://localhost:7071/api/orders \
  -H "Content-Type: application/json" \
  -d '{"productId":"SKU-001","quantity":3}'
# → {"productId":"SKU-001","quantity":3}
# (with 201 status and Location header)
Enter fullscreen mode Exit fullscreen mode

The Content-Type: application/json header is required. Without it, model binding won't kick in and you'll get a deserialization error.

Pattern 3: Route Parameters

For resource-oriented APIs, you'll want parameters embedded in the URL path rather than the query string:

public class ProductFunction(ILogger<ProductFunction> logger)
{
    [Function("GetProduct")]
    public IActionResult GetProduct(
        [HttpTrigger(AuthorizationLevel.Function, "get",
            Route = "products/{category:alpha}/{id:int?}")] HttpRequest req,
        string category, int? id)
    {
        logger.LogInformation("Looking up {Category}, id={Id}", category, id);
        return new OkObjectResult(new { category, id });
    }
}
Enter fullscreen mode Exit fullscreen mode

Route parameters are defined in curly braces inside the Route string and captured as method parameters matched by name. The runtime extracts category and id from the URL and passes them directly to your method.

Route constraints validate parameters before your code runs:

Constraint Meaning Example
:alpha Letters only electronics matches, 123 doesn't
:int Integer 42 matches, abc doesn't
:int? Optional integer Parameter can be omitted
:guid GUID format {id:guid}
:length(5) Exact string length {code:length(5)}
:min(1) Minimum integer value {page:min(1)}

If a constraint fails, the runtime returns a 404—your function never executes.

Test it:

curl http://localhost:7071/api/products/electronics/42
# → {"category":"electronics","id":42}

curl http://localhost:7071/api/products/electronics
# → {"category":"electronics","id":null}

curl http://localhost:7071/api/products/123/42
# → 404 (constraint :alpha rejects "123")
Enter fullscreen mode Exit fullscreen mode

You now have three working patterns that cover most real-world HTTP function scenarios.

Running and Testing Locally

Restart func start now that all three functions are in place. You should see all of them listed:

Azure Functions Core Tools
Core Tools Version: 4.6.0

Functions:

        CreateOrder: [POST] http://localhost:7071/api/orders

        GetProduct: [GET] http://localhost:7071/api/products/{category:alpha}/{id:int?}

        Hello: [GET] http://localhost:7071/api/Hello
Enter fullscreen mode Exit fullscreen mode

For quick reference, here are all three test commands together:

# Pattern 1: GET with query string
curl "http://localhost:7071/api/Hello?name=Azure"

# Pattern 2: POST with JSON body
curl -X POST http://localhost:7071/api/orders \
  -H "Content-Type: application/json" \
  -d '{"productId":"SKU-001","quantity":3}'

# Pattern 3: Route parameters
curl http://localhost:7071/api/products/electronics/42
Enter fullscreen mode Exit fullscreen mode

You can also use Postman, the REST Client extension for VS Code, or HTTPie—whatever fits your workflow.

A note on authorization: when running locally, AuthorizationLevel.Function is bypassed. You don't need to pass a function key. This makes local development frictionless. Once deployed, clients will need to include the key as a query parameter (?code=<key>) or in the x-functions-key header.

Troubleshooting

If things don't work on the first try, check these common issues:

"Can't determine project language" or "No job functions found"

The .csproj file is missing required settings. Verify it targets net10.0, has <AzureFunctionsVersion>v4</AzureFunctionsVersion>, and includes the Worker SDK packages. Run dotnet build separately to check for compilation errors.

Port 7071 already in use

Another Functions host (or another process) is using the default port. Either kill it or start on a different port:

func start --port 7072
Enter fullscreen mode Exit fullscreen mode

"Value cannot be null: provider" or storage connection errors

Azurite isn't running. The Functions runtime needs a storage emulator even for HTTP triggers. Start Azurite, or verify that local.settings.json contains:

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated"
  }
}
Enter fullscreen mode Exit fullscreen mode

UseDevelopmentStorage=true tells the SDK to connect to Azurite on its default ports.

JSON deserialization errors on POST requests

Two common causes: missing the Content-Type: application/json header, or property name mismatches between your JSON and your C# record. By default, the serializer is case-insensitive, so productId and ProductId both work. But the property names must exist on the target type.

"The listener for function X was unable to start"

Usually a runtime version mismatch. Confirm func --version returns 4.x and your .csproj has <AzureFunctionsVersion>v4</AzureFunctionsVersion>. If you recently upgraded the Core Tools, also run dotnet clean and rebuild.


Conclusion

We went from an empty directory to a running HTTP API with three production patterns:

  1. Query strings for simple reads (GET /api/Hello?name=Azure)
  2. JSON bodies for mutations (POST /api/orders)
  3. Route parameters for resource-oriented endpoints (GET /api/products/electronics/42)

All running locally, no Azure subscription needed.

The isolated worker model with ASP.NET Core integration gives you familiar types—HttpRequest, IActionResult, [FromBody]—so writing Azure Functions feels like writing any other .NET web API. The main difference is that Azure handles the hosting, scaling, and infrastructure.

The code samples in this article are deliberately simple. In a real project, you'd add input validation, error handling, and probably connect to a database or external service. But the patterns are the same—the [HttpTrigger] attribute, the route configuration, the model binding. Once you understand these building blocks, everything else is regular C#.

All code from this article is available in the azure-functions-samples repository—clone it, run func start, and experiment.

What's Next in This Series

Part 3: Beyond HTTP Triggers (coming soon) covers timer triggers for scheduled jobs, queue triggers for message processing, and blob triggers for reacting to file uploads. Same project structure, new event sources.

Part 4: Local Development Setup (coming soon) digs into the day-to-day workflow—debugging in VS Code, hot reload, and productivity tips.

Part 5: Understanding the Isolated Worker Model (coming soon) explains what "isolated" means, why Microsoft created this model, how it differs from the legacy in-process model, and what it means for your code.

And yes—we'll cover deploying to Azure with CI/CD via GitHub Actions later in the series, so everything you're building locally will make it to the cloud.


Azure Functions for .NET Developers Series


Top comments (0)