Skip to main content

AWS Lambda C# API Gateway: Build a Serverless REST API

Learn how to build a serverless REST API with AWS Lambda C# and API Gateway. Step-by-step .NET 8 tutorial with runnable code. Start building today.

Building a serverless REST API with AWS Lambda C# and Amazon API Gateway is one of the fastest ways to ship a production-ready backend without managing a single server. You write .NET 8 handler code, API Gateway routes HTTP requests to it, and AWS scales the whole thing from zero to thousands of requests per second automatically — and you pay only for the milliseconds your code actually runs.

In this tutorial you'll build a complete serverless REST API in C#: a Products service with GET, POST, and DELETE endpoints. We'll cover both the low-level Lambda proxy integration model (so you understand what's actually happening) and the ASP.NET Core Minimal API hosting model (so you can reuse your existing skills). Along the way you'll learn why serverless behaves the way it does, how to avoid cold-start pain, and the mistakes that trip up most teams on their first AWS Lambda .NET 8 project.

Why Use AWS Lambda C# with API Gateway?

A traditional ASP.NET Core API runs on an always-on server or container. You pay for idle time, you handle scaling, and you patch the OS. With AWS API Gateway + Lambda:

  • No servers to manage — AWS runs, patches, and scales the compute layer.
  • Pay-per-request pricing — the Lambda free tier includes 1 million requests and 400,000 GB-seconds per month; API Gateway HTTP APIs cost roughly $1 per million requests.
  • Automatic scaling — each concurrent request gets its own execution environment, up to your account concurrency limit.
  • First-class .NET support — .NET 8 is a managed Lambda runtime with Native AOT support, and the Amazon.Lambda.* NuGet packages are maintained by AWS.

The trade-offs are real too: cold starts, a 15-minute maximum execution time, a 6 MB synchronous payload limit, and a different mental model for state. We'll address each of these below.

How API Gateway Lambda Proxy Integration Works

Before writing code, understand the request flow. With Lambda proxy integration, API Gateway doesn't transform your request — it wraps the entire HTTP request (method, path, headers, query string, body) into a JSON event and invokes your function. Your function returns a JSON object describing the HTTP response (status code, headers, body), and API Gateway sends that back to the client.

There are two API Gateway flavours:

  • REST API (v1) — the original, feature-rich option with request validation, usage plans, API keys, and caching. Event type: APIGatewayProxyRequest.
  • HTTP API (v2) — newer, up to 70% cheaper, lower latency, simpler. Event type: APIGatewayHttpApiV2ProxyRequest.

For most new serverless REST API C# projects, HTTP API (v2) is the right default. Choose REST API only if you need usage plans, API keys, or request/response transformation.

Prerequisites and Project Setup

You'll need:

  • .NET 8 SDK
  • An AWS account with credentials configured (aws configure)
  • The AWS Lambda .NET tooling

Install the Lambda templates and the deployment tool:

dotnet new install Amazon.Lambda.Templates
dotnet tool install -g Amazon.Lambda.Tools

Create a new empty Lambda function project and add the API Gateway event package:

dotnet new lambda.EmptyFunction -n ProductsApi
cd ProductsApi/src/ProductsApi
dotnet add package Amazon.Lambda.APIGatewayEvents
dotnet add package Amazon.Lambda.Serialization.SystemTextJson

Building a C# Lambda Function with Proxy Integration

Here's a complete, runnable handler that implements three endpoints against an in-memory store. In a real system you'd swap the dictionary for DynamoDB, but keeping it simple lets us focus on the API Gateway contract.

using System.Collections.Concurrent;
using System.Net;
using System.Text.Json;
using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.Core;

[assembly: LambdaSerializer(
    typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]

namespace ProductsApi;

public record Product(string Id, string Name, decimal Price);

public class Function
{
    // Static state survives across warm invocations of the same environment.
    // It is NOT shared between concurrent environments — use DynamoDB for real data.
    private static readonly ConcurrentDictionary<string, Product> Store = new();

    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        PropertyNameCaseInsensitive = true
    };

    public APIGatewayHttpApiV2ProxyResponse FunctionHandler(
        APIGatewayHttpApiV2ProxyRequest request, ILambdaContext context)
    {
        var method = request.RequestContext.Http.Method.ToUpperInvariant();
        var path = request.RawPath;

        context.Logger.LogInformation($"{method} {path}");

        try
        {
            return (method, path) switch
            {
                ("GET", "/products") => Ok(Store.Values),
                ("GET", _) when path.StartsWith("/products/") => GetById(path[10..]),
                ("POST", "/products") => Create(request.Body),
                ("DELETE", _) when path.StartsWith("/products/") => Delete(path[10..]),
                _ => Error(HttpStatusCode.NotFound, "Route not found")
            };
        }
        catch (JsonException ex)
        {
            return Error(HttpStatusCode.BadRequest, $"Invalid JSON: {ex.Message}");
        }
        catch (Exception ex)
        {
            context.Logger.LogError(ex.ToString());
            return Error(HttpStatusCode.InternalServerError, "Unexpected error");
        }
    }

    private static APIGatewayHttpApiV2ProxyResponse GetById(string id) =>
        Store.TryGetValue(id, out var product)
            ? Ok(product)
            : Error(HttpStatusCode.NotFound, $"Product {id} not found");

    private static APIGatewayHttpApiV2ProxyResponse Create(string? body)
    {
        if (string.IsNullOrWhiteSpace(body))
            return Error(HttpStatusCode.BadRequest, "Body is required");

        var input = JsonSerializer.Deserialize<Product>(body, JsonOptions);
        if (input is null || string.IsNullOrWhiteSpace(input.Name) || input.Price <= 0)
            return Error(HttpStatusCode.BadRequest, "Name and a positive Price are required");

        var product = input with { Id = Guid.NewGuid().ToString("N") };
        Store[product.Id] = product;

        return Json(HttpStatusCode.Created, product);
    }

    private static APIGatewayHttpApiV2ProxyResponse Delete(string id) =>
        Store.TryRemove(id, out _)
            ? new APIGatewayHttpApiV2ProxyResponse { StatusCode = 204 }
            : Error(HttpStatusCode.NotFound, $"Product {id} not found");

    private static APIGatewayHttpApiV2ProxyResponse Ok(object payload) =>
        Json(HttpStatusCode.OK, payload);

    private static APIGatewayHttpApiV2ProxyResponse Error(HttpStatusCode status, string message) =>
        Json(status, new { error = message });

    private static APIGatewayHttpApiV2ProxyResponse Json(HttpStatusCode status, object payload) =>
        new()
        {
            StatusCode = (int)status,
            Body = JsonSerializer.Serialize(payload, JsonOptions),
            Headers = new Dictionary<string, string>
            {
                ["Content-Type"] = "application/json"
            }
        };
}

What's happening here, and why

  • The [assembly: LambdaSerializer] attribute tells the runtime how to deserialize the incoming JSON event. Without it, your handler can't accept typed parameters.
  • We always return a proxy response object. If you throw an unhandled exception, API Gateway returns a generic 502 Bad Gateway with no useful detail. Catching and mapping errors to proper status codes is essential for a usable API.
  • Static fields are a deliberate optimization. Lambda reuses execution environments between invocations, so expensive objects (SDK clients, HttpClient, serializer options) should be created once, statically. Just never rely on static state for correctness — every concurrent request may hit a different environment.
  • Case-insensitive JSON matters because clients send camelCase while C# records use PascalCase.

Deploying the Serverless REST API with AWS SAM

You can click through the console, but infrastructure-as-code is the only sane way to manage a serverless API. Here's a minimal AWS SAM template that creates the function and an HTTP API with all routes wired up. Save it as template.yaml in the project root:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Globals:
  Function:
    Runtime: dotnet8
    MemorySize: 512
    Timeout: 30
    Architectures: [arm64]

Resources:
  ProductsFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: ./src/ProductsApi/
      Handler: ProductsApi::ProductsApi.Function::FunctionHandler
      Events:
        ListProducts:
          Type: HttpApi
          Properties: { Path: /products, Method: GET }
        GetProduct:
          Type: HttpApi
          Properties: { Path: /products/{id}, Method: GET }
        CreateProduct:
          Type: HttpApi
          Properties: { Path: /products, Method: POST }
        DeleteProduct:
          Type: HttpApi
          Properties: { Path: /products/{id}, Method: DELETE }

Outputs:
  ApiUrl:
    Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com"

Build and deploy:

sam build
sam deploy --guided

The handler string format is Assembly::Namespace.Class::Method — getting this wrong is the single most common deployment error, and it surfaces only at invocation time as a cryptic "Could not find the specified handler" message.

Test it with curl once SAM prints your API URL:

curl -X POST https://abc123.execute-api.us-east-1.amazonaws.com/products \
  -H "Content-Type: application/json" \
  -d '{"name":"Keyboard","price":79.99}'

curl https://abc123.execute-api.us-east-1.amazonaws.com/products

Alternative: Run ASP.NET Core Minimal APIs on Lambda

Manually routing on (method, path) works for small APIs but doesn't scale to dozens of endpoints. AWS provides Amazon.Lambda.AspNetCoreServer.Hosting, which translates API Gateway events into standard ASP.NET Core requests. You get routing, model binding, validation, middleware, and dependency injection — and the same code runs locally with dotnet run.

dotnet new web -n ProductsMinimalApi
cd ProductsMinimalApi
dotnet add package Amazon.Lambda.AspNetCoreServer.Hosting
using System.Collections.Concurrent;

var builder = WebApplication.CreateBuilder(args);

// One line turns this app into a Lambda function when running on AWS.
// Locally, it's a no-op and the app runs on Kestrel as normal.
builder.Services.AddAWSLambdaHosting(LambdaEventSource.HttpApi);
builder.Services.AddSingleton<ConcurrentDictionary<string, Product>>();

var app = builder.Build();

app.MapGet("/products", (ConcurrentDictionary<string, Product> store) =>
    Results.Ok(store.Values));

app.MapGet("/products/{id}", (string id, ConcurrentDictionary<string, Product> store) =>
    store.TryGetValue(id, out var p) ? Results.Ok(p) : Results.NotFound());

app.MapPost("/products", (Product input, ConcurrentDictionary<string, Product> store) =>
{
    if (string.IsNullOrWhiteSpace(input.Name) || input.Price <= 0)
        return Results.BadRequest(new { error = "Name and a positive Price are required" });

    var product = input with { Id = Guid.NewGuid().ToString("N") };
    store[product.Id] = product;
    return Results.Created($"/products/{product.Id}", product);
});

app.MapDelete("/products/{id}", (string id, ConcurrentDictionary<string, Product> store) =>
    store.TryRemove(id, out _) ? Results.NoContent() : Results.NotFound());

app.Run();

public record Product(string Id, string Name, decimal Price);

In the SAM template, change the handler to the assembly name (Handler: ProductsMinimalApi) and use a single catch-all route:

Events:
  ProxyResource:
    Type: HttpApi
    Properties: { Path: /{proxy+}, Method: ANY }

Which approach should you choose? Use the raw proxy handler for single-purpose microfunctions where minimal cold start matters most. Use the ASP.NET Core hosting model when you have more than a handful of endpoints, want local debugging with breakpoints, or are migrating an existing API. The ASP.NET Core model adds roughly 100–200 ms to cold starts, which is usually acceptable.

AWS Lambda C# Best Practices

1. Tame cold starts

A cold start happens when Lambda has to create a fresh execution environment — downloading your package and starting the .NET runtime. For .NET 8 this typically costs 400–900 ms. To reduce it:

  • Use arm64 (Graviton) — it's ~20% cheaper and often faster for .NET workloads.
  • Enable ReadyToRun — add <PublishReadyToRun>true</PublishReadyToRun> to your .csproj to pre-compile IL and skip JIT work at startup.
  • Consider Native AOT — the lambda.NativeAOT template can bring cold starts under 200 ms, at the cost of reflection restrictions (you must use System.Text.Json source generators).
  • Right-size memory — CPU scales with memory. 512 MB–1024 MB is a sweet spot for .NET; 128 MB will make cold starts painfully slow.
  • Trim dependencies — every assembly in your package must be loaded. Don't reference the whole AWS SDK when you need one service.

2. Initialize expensive resources once

Create SDK clients, HttpClient, and database connections in the constructor or a static initializer, never inside the handler. Lambda gives the initialization phase a CPU boost, so it's also the cheapest place to do that work.

3. Use structured logging

Log JSON so CloudWatch Logs Insights can query it. Include the request.RequestContext.RequestId in every log line to trace a single request end-to-end. Set Logging.LogLevel in your function configuration rather than hard-coding it.

4. Never store state in the function

The in-memory dictionary in our examples is for demonstration only. Real APIs should use DynamoDB (the natural pairing for serverless — pay-per-request, single-digit millisecond reads) or Aurora Serverless if you need relational queries. Use AWSSDK.DynamoDBv2 and inject IAmazonDynamoDB.

5. Secure the API

HTTP APIs support JWT authorizers natively — point one at Amazon Cognito or any OIDC provider and API Gateway rejects unauthenticated requests before your function ever runs (and before you're billed). For internal APIs, use IAM authorization. Never rely solely on validation inside the function.

6. Grant least-privilege IAM permissions

SAM's Policies property accepts templates like DynamoDBCrudPolicy. Don't attach AdministratorAccess to a function "to get it working" — it will quietly ship to production.

Common Pitfalls When Building a Serverless API on AWS

  • Wrong handler stringAssembly::Namespace.Class::Method. Triple-check it.
  • Mixing v1 and v2 event types — if you configure an HTTP API but use APIGatewayProxyRequest, fields like HttpMethod will be null. Match the event class to the API type.
  • Forgetting CORS — browsers will block your API. Configure CorsConfiguration on the HTTP API in SAM rather than hand-writing headers in every response.
  • Blocking on async code.Result and .Wait() can deadlock and waste billed time. Make the handler async Task<...> and await properly.
  • Binary responses — if you return images or files, set IsBase64Encoded = true and base64-encode the body, or API Gateway will corrupt it.
  • Ignoring the 29-second API Gateway timeout — even though Lambda allows 15 minutes, API Gateway will drop the connection at 29 seconds (30 s for HTTP APIs). Long jobs should return 202 Accepted and process asynchronously via SQS.
  • Deploying from the console — you'll lose track of what's deployed. Use SAM, CDK, or Terraform from day one.

Conclusion: Key Takeaways for AWS Lambda C# APIs

Combining AWS API Gateway with an AWS Lambda C# function gives you a scalable, low-cost serverless REST API with almost no operational overhead. Here's what to remember:

  • Choose HTTP API (v2) unless you specifically need REST API features like usage plans or API keys.
  • Lambda proxy integration hands you the full HTTP request as JSON and expects a structured response — always return proper status codes instead of throwing.
  • For small APIs, a raw APIGatewayHttpApiV2ProxyRequest handler is lean and fast; for larger ones, Amazon.Lambda.AspNetCoreServer.Hosting lets you use Minimal APIs with routing, DI, and local debugging.
  • Fight cold starts with arm64, ReadyToRun or Native AOT, sensible memory sizing, and static initialization of expensive resources.
  • Keep functions stateless, back them with DynamoDB, secure them with JWT or IAM authorizers, and deploy everything through infrastructure-as-code with AWS SAM.

From here, a natural next step is replacing the in-memory store with DynamoDB, adding a Cognito JWT authorizer, and wiring up a GitHub Actions pipeline that runs sam deploy on every merge. With those three pieces in place, you'll have a genuinely production-grade serverless API built entirely in C# and .NET 8.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...