
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 Gatewaywith 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
camelCasewhile C# records usePascalCase.
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.csprojto pre-compile IL and skip JIT work at startup. - Consider Native AOT — the
lambda.NativeAOTtemplate 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 string —
Assembly::Namespace.Class::Method. Triple-check it. - Mixing v1 and v2 event types — if you configure an HTTP API but use
APIGatewayProxyRequest, fields likeHttpMethodwill be null. Match the event class to the API type. - Forgetting CORS — browsers will block your API. Configure
CorsConfigurationon the HTTP API in SAM rather than hand-writing headers in every response. - Blocking on async code —
.Resultand.Wait()can deadlock and waste billed time. Make the handlerasync Task<...>and await properly. - Binary responses — if you return images or files, set
IsBase64Encoded = trueand 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 Acceptedand 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
APIGatewayHttpApiV2ProxyRequesthandler is lean and fast; for larger ones,Amazon.Lambda.AspNetCoreServer.Hostinglets 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.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment