# Running on AWS Lambda

> Serve the Inngest endpoint from an AWS Lambda function behind API Gateway or a function URL, as a managed runtime function or a Native AOT executable, and plan around the timeouts and sizes of the platform.

Web page: https://damikun.github.io/inngest-dotnet/guides/aws-lambda/

`Inngest.AwsLambda` serves the Inngest endpoint from a Lambda function. Inngest calls the function through an API
Gateway REST API or HTTP API, or through a Lambda function URL. The package turns each event into the request the
SDK's serve handler reads, and the handler's answer into the response API Gateway expects. Introspection, syncs,
calls, request signing and response signing work as they do in an ASP.NET Core app, and every call from Inngest is one
invocation.

A run that sleeps or waits for an event holds no invocation while it does: Inngest keeps the run and calls the
function again when it is due. [How it works](https://damikun.github.io/inngest-dotnet/how-it-works.md) shows the calls of a run.

## Install

```bash
dotnet add package Inngest.AwsLambda
```

It brings in `Inngest`, `Amazon.Lambda.APIGatewayEvents` for the event types and `Amazon.Lambda.Core` for the
Lambda context. It does not need ASP.NET Core.

## One method for each event shape

What Lambda passes to the function depends on what invoked it. `InngestLambdaHandler` has a `HandleAsync` for each
shape:

| Invoked by | Payload format | Event and response of `HandleAsync` |
| - | - | - |
| API Gateway REST API | 1.0 | `APIGatewayProxyRequest`, `APIGatewayProxyResponse` |
| API Gateway HTTP API | 2.0, the default | `APIGatewayHttpApiV2ProxyRequest`, `APIGatewayHttpApiV2ProxyResponse` |
| Lambda function URL | 2.0 | `APIGatewayHttpApiV2ProxyRequest`, `APIGatewayHttpApiV2ProxyResponse` |

A function serves one shape. An HTTP API integration that is set to payload format 1.0 sends the REST shape.

Build the handler once, when the function starts, from the service provider of an app that called `AddInngest`. It
fails there, and not on the first event, when `AddInngest` is missing. Every invocation then runs in a service scope
of its own, so scoped services work as they do in a web request. The examples below use this function:

```csharp title="SendWelcomeEmail.cs"
using Inngest;

public sealed record UserSignedUp(string UserId, string Email);

public sealed record Welcome(string UserId);

[InngestFunction("send-welcome-email")]
[EventTrigger("user/signed-up")]
public sealed class SendWelcomeEmail : InngestFunction<UserSignedUp, Welcome>
{
    public override async Task<Welcome> ExecuteAsync(FunctionContext<UserSignedUp> context, CancellationToken cancellationToken)
    {
        var user = context.Event.Data;

        await context.Step.RunAsync("send-welcome", () => $"Welcome, {user.Email}!");
        await context.Step.SleepAsync("wait-before-tips", TimeSpan.FromDays(1));
        await context.Step.RunAsync("send-tips", () => $"Three things to try first, {user.Email}.");

        return new Welcome(user.UserId);
    }
}
```

## A managed runtime function

The managed runtime creates the function's class once per execution environment, so its constructor is the place to
build the handler. The method gets the event, in the shape of the invoking source, through a serializer you name with
`LambdaSerializer`:

```csharp title="Function.cs"
using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.Core;
using Amazon.Lambda.Serialization.SystemTextJson;
using Inngest;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

public sealed class Function
{
    private readonly InngestLambdaHandler _inngest;

    public Function()
    {
        var services = new ServiceCollection();
        services.AddLogging(logging => logging.AddConsole());
        services
            .AddInngest(options => options.AppId = "shop")
            .AddFunction<SendWelcomeEmail>();

        _inngest = new InngestLambdaHandler(services.BuildServiceProvider());
    }

    [LambdaSerializer(typeof(DefaultLambdaJsonSerializer))]
    public Task<APIGatewayHttpApiV2ProxyResponse> HandleAsync(APIGatewayHttpApiV2ProxyRequest request, ILambdaContext context) =>
        _inngest.HandleAsync(request, context);
}
```

Use the `dotnet10` runtime (AWS deprecates `dotnet8` on 10 November 2026), with the handler
`Shop::Function::HandleAsync`: the assembly name, the type and the method. For a REST API, take
`APIGatewayProxyRequest` and `APIGatewayProxyResponse` instead. Add
`Amazon.Lambda.Serialization.SystemTextJson` for `DefaultLambdaJsonSerializer`, and
`Microsoft.Extensions.Logging.Console` for the console logger, whose output goes to CloudWatch Logs.

## A Native AOT function

A Native AOT function is an executable that runs on the `provided.al2023` custom runtime, with
`Amazon.Lambda.RuntimeSupport` taking events from Lambda. Nothing in `Inngest.AwsLambda` uses reflection, and a
minimal function like the one below publishes without trimming or AOT warnings and answers events. Name the
executable `bootstrap`, which is what the custom runtime starts:

```xml title="Shop.csproj"
<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net10.0</TargetFramework>
  <AssemblyName>bootstrap</AssemblyName>
  <PublishAot>true</PublishAot>
  <InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
```

```bash
dotnet add package Inngest.AwsLambda
dotnet add package Amazon.Lambda.RuntimeSupport
dotnet add package Amazon.Lambda.Serialization.SystemTextJson
dotnet add package Microsoft.Extensions.Logging.Console
```

The app registers its functions as in any other Native AOT app, with the types they serialize in a
`JsonSerializerContext` in `InngestOptions.SerializerOptions`; see [Native AOT](https://damikun.github.io/inngest-dotnet/guides/native-aot.md).
A second context names the event types, which `SourceGeneratorLambdaJsonSerializer` reads events and writes
responses with, and `LambdaBootstrapBuilder` runs the loop:

```csharp title="Program.cs"
using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.Core;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
using Inngest;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

var services = new ServiceCollection();
services.AddLogging(logging => logging.AddConsole());
services
    .AddInngest(options =>
    {
        options.AppId = "shop";
        options.SerializerOptions.TypeInfoResolverChain.Insert(0, FunctionJson.Default);
    })
    .AddFunction<SendWelcomeEmail>();

await using var provider = services.BuildServiceProvider();
var inngest = new InngestLambdaHandler(provider);

Func<APIGatewayHttpApiV2ProxyRequest, ILambdaContext, Task<APIGatewayHttpApiV2ProxyResponse>> handler = inngest.HandleAsync;
await LambdaBootstrapBuilder
    .Create(handler, new SourceGeneratorLambdaJsonSerializer<LambdaJson>())
    .Build()
    .RunAsync();
```

```csharp title="Json.cs"
using System.Text.Json.Serialization;
using Amazon.Lambda.APIGatewayEvents;

[JsonSerializable(typeof(UserSignedUp))]
[JsonSerializable(typeof(Welcome))]
internal sealed partial class FunctionJson : JsonSerializerContext;

[JsonSerializable(typeof(APIGatewayHttpApiV2ProxyRequest))]
[JsonSerializable(typeof(APIGatewayHttpApiV2ProxyResponse))]
internal sealed partial class LambdaJson : JsonSerializerContext;
```

`samples/Inngest.LambdaAotSample` in the repository is this function, with the same three files. CI publishes it with
Native AOT, where a trimming or AOT warning fails the build.

The package ships no context for the event types, and the context above lists the request and response of one event
shape only. The REST and HTTP API request types have nested types with the same names, which one context cannot
hold: the generator warns (`SYSLIB1031`), and the executable fails on its first event with a `NotSupportedException`
about the metadata of `ProxyRequestContext`. List the pair a function serves, `APIGatewayProxyRequest` and
`APIGatewayProxyResponse` for a REST API, and nothing else.

Native AOT does not compile across operating systems, so build on Linux for `linux-x64` or `linux-arm64`, whichever
architecture the function uses, in a container that matches Lambda's Amazon Linux 2023. `dotnet lambda deploy-function`
of `Amazon.Lambda.Tools` builds in such a container with Docker.

## Deploy it

The function needs its Inngest keys, and Inngest needs a route to it. With AWS SAM, an HTTP API route to the
managed runtime function looks like this:

```yaml title="template.yaml"
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Parameters:
  InngestSigningKey:
    Type: String
    NoEcho: true
  InngestEventKey:
    Type: String
    NoEcho: true

Resources:
  InngestFunction:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: dotnet10
      Handler: Shop::Function::HandleAsync
      CodeUri: ./publish/
      MemorySize: 512
      Timeout: 29
      Environment:
        Variables:
          INNGEST_SIGNING_KEY: !Ref InngestSigningKey
          INNGEST_EVENT_KEY: !Ref InngestEventKey
      Events:
        Inngest:
          Type: HttpApi
          Properties:
            Path: /api/inngest
            Method: ANY
```

A Native AOT build uses `Runtime: provided.al2023`, `Handler: bootstrap` and an `Architectures` entry that matches the
build. A function URL replaces the `Events` with `FunctionUrlConfig`, whose `AuthType: NONE` makes the URL public:

```yaml
      FunctionUrlConfig:
        AuthType: NONE
```

Keep the function URL's invoke mode at `BUFFERED`, which is the default: the handler returns its answer as a response
and writes nothing to a response stream, which is what `RESPONSE_STREAM` mode takes.

Inngest has to reach the function, so the route is public, and Inngest's signature protects it: in cloud mode the SDK
answers a call, an introspection request or an in-band sync that is not signed with your signing key with `401`, and
it signs its responses. A sync that Inngest did not start is the exception. An unsigned out-of-band `PUT` is accepted
by default, and without `ServeOrigin` the URL it registers comes from the request, so anyone who can reach a public
URL can make the app register a wrong address until the next real sync. On a public URL, set `ServeOrigin` and
`ServePath`, and turn unsigned syncs off with `AllowUnsignedSync = false` (`INNGEST_ENABLE_UNAUTHED_SYNC=false`); the
[Syncs](https://damikun.github.io/inngest-dotnet/guides/configuration.md#syncs) section of the configuration guide has the details. A bare
`curl -X PUT` from a deploy script is then refused, so sync from the Inngest dashboard, where Inngest sends a signed
request.

Route only the Inngest path to the function, and keep `INNGEST_DEV` unset. The keys come from the environment of the
function, which you can fill from Secrets Manager or Parameter Store; see
[Configuration](https://damikun.github.io/inngest-dotnet/guides/configuration.md). Add the app in the Inngest dashboard with its URL; the URL of
a REST API includes its stage. After later deploys, sync from the dashboard, or with a `PUT` as in
[Deploying](https://damikun.github.io/inngest-dotnet/guides/deploying.md#syncing) where unsigned syncs are still on.

## The URL Inngest calls

A sync tells Inngest which URL to call. An in-band sync, which Inngest starts when you add the app in its dashboard,
carries the URL Inngest used in its signed body, and the SDK reports that one. An out-of-band sync, a `PUT` from a
deploy script or from the dev server, carries none, so the SDK builds the URL from the event: the scheme from
`X-Forwarded-Proto` (its last value, which the closest proxy added; `https` when the header is missing or that value
is anything but `http`), the host from the `Host` header (the domain name of the request context when there is none),
and the path as the caller used it, with the query string, minus `deployId`. What API Gateway puts in the path decides
whether the stage is part of it:

| Invoked through | Path in the URL |
| - | - |
| REST API on `execute-api` | `requestContext.path`, which starts with the stage: `/prod/api/inngest`. `path` leaves the stage out. |
| REST API on a custom domain | `requestContext.path`, which has the base path mapping and not the stage: `/test/api/inngest`. `path` has the mapping too. |
| HTTP API, `$default` stage | `rawPath`. |
| HTTP API, named stage | `rawPath`, which starts with the stage. |
| Function URL | `rawPath`. |

`requestContext.path` is used when it is `path` with something in front, and `path` otherwise.
`Amazon.Lambda.AspNetCoreServer` finds the stage the same way, as the request context's path minus the path. The
custom domain row is what a real event of a REST API with a base path mapping looked like when it was logged in
[aws-lambda-dotnet issue 127](https://github.com/aws/aws-lambda-dotnet/issues/127), so a REST API needs no `ServePath`
for its mapping.

Some setups hide the public address from the event, and then an out-of-band sync reports the wrong URL. An HTTP API
behind a custom domain with an API mapping is one: AWS notes that `rawPath` leaves the mapping out. Give the SDK the
path, or integrate the HTTP API with payload format 1.0 and use the REST overload, whose `path` has the mapping, as AWS
suggests. A CloudFront distribution in front of the API or the function URL is another, since the function sees the
host CloudFront used to reach it. Check the URL in the Inngest dashboard after the first sync, and give the SDK the
address when it is wrong, as in [Behind a proxy](https://damikun.github.io/inngest-dotnet/guides/configuration.md#behind-a-proxy):

```csharp
options.ServeOrigin = new Uri("https://api.example.com");
options.ServePath = "/v1/api/inngest";
```

`INNGEST_SERVE_ORIGIN` and `INNGEST_SERVE_PATH` do the same.

## Time and size

Several limits apply to a call from Inngest, and the lowest one wins. These are AWS's at the time of writing, so check
its [quotas](https://docs.aws.amazon.com/apigateway/latest/developerguide/limits.html) before relying on them:

| Limit | Value |
| - | - |
| API Gateway REST API, time to answer | 29 seconds. A Regional or private API can be given more through a quota increase, an edge-optimized API cannot. |
| API Gateway HTTP API, time to answer | 30 seconds, not adjustable. |
| Lambda function URL | The function's timeout. |
| Lambda function timeout | 15 minutes at most, and 3 seconds until you set it. |
| Request and response | 6 MB each for a Lambda invocation. API Gateway allows 10 MB. |

Set the function's timeout on purpose. The default of 3 seconds ends any call whose step takes longer. Behind API
Gateway, do not set it above the gateway's limit: the gateway answers Inngest with an error at its limit while the
function runs on, and Inngest retries the call meanwhile. Keep each step well inside the lowest limit, and split
long work into steps.

A step's result and a function's output travel in the response, so they have to fit in 6 MB; return an id or a link
to stored data rather than the data. When a run's events and steps are too large for the request, Inngest trims it
(`use_api`) and the SDK reads them from Inngest's API, so the size of the request is not the limit.

By default every step is a call of its own, and one more call ends the run. With
[checkpointing](https://damikun.github.io/inngest-dotnet/reference/api.md#options) one call runs steps one after another and saves
each to Inngest as it completes. `MaxRuntime` is how long the call may start new steps; past it, the next step is left
to Inngest, which calls again. A step that has started still runs to its end, so `MaxRuntime` plus your longest step
has to fit in the lowest limit above, with a cold start: the first call to a new execution environment waits for the
runtime to start and the app to build its services before any step runs, and the gateway counts that wait too. For a
REST API's 29 seconds, with steps of a few seconds, 15 seconds leaves room:

```csharp
options.Checkpointing = new CheckpointingOptions { MaxRuntime = TimeSpan.FromSeconds(15) };
```

When `MaxRuntime` is not set, a call runs steps for up to 10 seconds. Behind a function URL there is no gateway in
between, and the function's timeout is the only limit.

## What an invocation does

- **Scope.** Each invocation runs its call in a service scope of its own, which is disposed before the response is
  returned. Functions and middleware are resolved from it.
- **Cancellation.** The `CancellationToken` that functions and steps receive is never cancelled. Lambda sends the
  function no signal before it ends an invocation that has run out of time, and the handler does not make one up
  from `ILambdaContext.RemainingTime`: cancelling shortly before the deadline would only turn the error that Inngest
  retries anyway into an error of the handler's own, take time from calls that would have finished, and keep stuck
  work alive in an environment that Lambda would otherwise reset. `MaxRuntime` is the setting that keeps calls inside
  their time.
- **Streaming.** The response is one buffered response. It cannot send the 201 and the spaces that streaming writes
  while a call runs, so `InngestOptions.Streaming` has no effect, and building the handler logs a warning when it is
  on. Lambda can stream responses to .NET functions, through function URLs in `RESPONSE_STREAM` mode and REST APIs with
  a streaming integration, which this package does not use.
- **Background work.** Lambda freezes the execution environment once the handler returns, which pauses anything left
  running, such as a batch exporter's timer. Flush telemetry before the invocation ends, as `Inngest.Sentry` does
  when the call's scope ends.
- **Connect.** A Connect worker is a process that stays up with a WebSocket open, which an invocation is not; use the
  endpoint on Lambda.

## MapInngest on Lambda

`Amazon.Lambda.AspNetCoreServer.Hosting` runs an ASP.NET Core app as a Lambda function. `AddAWSLambdaHosting` replaces
Kestrel, and `MapInngest` works as it does anywhere. It comes from `Inngest.AspNetCore`, and nothing from
`Inngest.AwsLambda` is used:

```csharp title="Program.cs"
using Amazon.Lambda.AspNetCoreServer.Hosting;
using Inngest;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAWSLambdaHosting(LambdaEventSource.HttpApi);
builder.Services
    .AddInngest(options => options.AppId = "shop")
    .AddFunction<SendWelcomeEmail>();

var app = builder.Build();

app.MapInngest("/api/inngest");

app.Run();
```

Prefer this when the function is an ASP.NET Core app already, serves other routes beside Inngest's, or needs the
ASP.NET Core pipeline, such as authentication, middleware or health checks. Prefer `Inngest.AwsLambda` when the
function only serves Inngest: the event goes straight to the handler, with no web stack to start. The framework
Inngest shows for an app is `aspnetcore` with `MapInngest` and `aws-lambda` with the handler.

The AWS package marks the overloads of `AddAWSLambdaHosting` that take no serializer as unsafe for trimming. A minimal
`MapInngest` app that passes a `SourceGeneratorLambdaJsonSerializer<T>` to `AddAWSLambdaHosting` published with Native
AOT without warnings, and answered events, when checked with `Amazon.Lambda.AspNetCoreServer.Hosting` 2.2.1 on
.NET 10. That package is AWS's, so read its release notes for what later versions change.
