Skip to content

Inngest in Azure Functions

Inngest.AzureFunctions serves the Inngest endpoint from an HTTP-triggered function of an Azure Functions app in the isolated worker model. You write one short function in your project and call ServeInngestAsync in it. The package answers introspection, syncs and calls, and checks signatures, as MapInngest does in an ASP.NET Core app. It works with both HTTP models of the worker: the built-in one (HttpRequestData and HttpResponseData) and the ASP.NET Core integration (ConfigureFunctionsWebApplication).

The functions are the same as everywhere else; only the way Inngest reaches them changes. An Azure Functions app limits how long one HTTP call can take, which shapes how you run long steps. See The 230-second limit.

Start from a Functions project for the isolated worker, such as the one func init --worker-runtime dotnet-isolated creates. It needs version 2.0 or later of Microsoft.Azure.Functions.Worker, and the Microsoft.Azure.Functions.Worker.Extensions.Http package for the HttpTrigger attribute. Install Inngest.AzureFunctions from NuGet. It brings in Inngest and Inngest.AspNetCore:

Terminal window
dotnet add package Inngest.AzureFunctions

Like Inngest.AspNetCore, the package references the ASP.NET Core shared framework, so the runtime that runs the app needs it, as it does for the worker’s own ASP.NET Core integration. An app that uses the built-in HTTP model needs it now too.

A function is a class, as in Getting started. It is resolved from the services of the invocation, so it can take what you registered:

SendWelcomeEmail.cs
using Inngest;
using Microsoft.Extensions.Logging;
public sealed record UserSignedUp(string Email);
[InngestFunction("send-welcome-email")]
[EventTrigger("user/signed.up")]
public sealed class SendWelcomeEmail(ILogger<SendWelcomeEmail> logger) : InngestFunction<UserSignedUp>
{
public override async Task<object?> ExecuteAsync(FunctionContext<UserSignedUp> context, CancellationToken cancellationToken)
{
var message = await context.Step.RunAsync("compose", () => $"Welcome, {context.Event.Data.Email}!");
await context.Step.SleepAsync("wait", TimeSpan.FromMinutes(10));
await context.Step.RunAsync("send", () => logger.LogInformation("{Message}", message));
return message;
}
}

Program.cs registers the SDK like any other app. There is no MapInngest: the Functions host routes requests to functions.

Program.cs
using Inngest;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
builder.Services
.AddInngest(options => options.AppId = "shop")
.AddFunction<SendWelcomeEmail>();
builder.Build().Run();

ConfigureFunctionsWebApplication turns on the ASP.NET Core integration, which needs the Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore package. Leave it out to use the built-in model. The two models differ in one respect that matters here.

The function that serves Inngest is yours to write, because its name, route and authorization level are yours to choose. This is all of it:

InngestEndpoint.cs
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
public sealed class InngestEndpoint
{
[Function("inngest")]
public Task<HttpResponseData> RunAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", "put", "post", Route = "inngest")] HttpRequestData request) =>
request.ServeInngestAsync();
}
  • All three methods. Inngest uses GET for introspection, PUT to sync the app and POST to call functions.
  • AuthorizationLevel.Anonymous. Inngest sends no function key. It signs its requests with your signing key. In cloud mode the SDK refuses a call, an in-band sync or an introspection request whose signature does not verify, before it does anything. An out-of-band sync is the exception, which the SDK accepts without a signature by default, as the TypeScript SDK does. See The URL Inngest calls for what to set on a public endpoint.
  • The route. The Functions host puts its route prefix, api by default, in front of inngest, so Inngest calls /api/inngest, the path it uses by default. Set extensions.http.routePrefix in host.json to change the prefix, or to an empty string to remove it. See the HTTP trigger reference.
  • HttpRequestData is the one parameter. It carries the FunctionContext, so the SDK runs your functions in the services of the invocation and cancels them when the host cancels the invocation.

Put the settings in local.settings.json, next to the ones the template created. INNGEST_DEV=1 runs the SDK in dev mode, where it contacts the dev server at http://localhost:8288 and verifies nothing:

local.settings.json
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"INNGEST_DEV": "1"
}
}

Start the app with Azure Functions Core Tools, which listens on port 7071 unless told otherwise:

Terminal window
func start

Start the Inngest dev server and point it at the function, with npx, or with Docker when Node.js is not installed:

Terminal window
npx --ignore-scripts=false inngest-cli@latest dev -u http://localhost:7071/api/inngest
docker run --rm -p 8288:8288 inngest/inngest:v1.49.0 inngest dev -u http://host.docker.internal:7071/api/inngest

Open http://localhost:8288. The app and its functions are listed once the dev server has synced it, and sending an event from its UI starts a run. The container reaches the app at host.docker.internal, since localhost inside it is the container itself.

Do not set INNGEST_DEV in Azure; cloud mode is the default. Set the keys from the Inngest dashboard as application settings, or as Key Vault references, which reach the SDK like any other setting:

Application setting Value
INNGEST_SIGNING_KEY The signing key of the environment
INNGEST_EVENT_KEY The event key, to send events from the app

Then add the app in the Inngest dashboard with the URL of the function, such as https://shop.azurewebsites.net/api/inngest, and sync again after each deploy. A sync from the dashboard is in-band: Inngest sends the URL it called in a signed request, and the SDK registers that one. Deploying also shows a curl -X PUT from a deploy pipeline. That is an out-of-band sync, and the next section says what to set for it.

To check that the function is reachable, send a GET to it. In dev mode it answers with the introspection body; in cloud mode it answers an unsigned request with a 401.

A sync that starts from the app’s side, such as curl -X PUT https://shop.azurewebsites.net/api/inngest from a deploy pipeline, is out-of-band: the SDK registers the URL the request arrived on and posts its configuration to the Inngest it is configured for. By default it accepts such a sync without a signature. The function is anonymous and public, so anyone can send one, and unless ServeOrigin is set, the URL it registers comes from the request, which the sender chooses.

What the app sees as that address is what the Functions host saw. In both HTTP models the worker is given the scheme, host name, path and query of the host’s own request, the ASP.NET Core integration through the X-Forwarded-* headers that the host sends and the integration applies. The host’s source corrects the scheme and the host name from the front end’s headers (X-Forwarded-Proto and Disguised-Host) only on Linux Consumption and Linux Dedicated plans. Nothing in it does that on Windows plans, or in a container behind TLS termination, where the address can carry the scheme http or the host name of the hop before the app. The path has the route prefix in it, so it reads /api/inngest unless you changed the prefix.

So in production, say where Inngest should call, and the path too if a gateway such as Azure Front Door, API Management or Application Gateway changes it, in the settings or as INNGEST_SERVE_ORIGIN and INNGEST_SERVE_PATH:

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

With the origin fixed, an unsigned sync can only make the SDK register that URL again. To refuse unsigned syncs altogether, turn them off, in the settings or with INNGEST_ENABLE_UNAUTHED_SYNC=false:

options.AllowUnsignedSync = false;

Then a curl -X PUT from a pipeline is answered with a 401, and you sync from the Inngest dashboard, whose syncs are in-band and signed. See Syncs and Behind a proxy. The other settings are the same as in any app: Configuration.

An HTTP-triggered function has to answer within 230 seconds, whatever its functionTimeout. After that the Azure load balancer times the request out and returns a 502; the function goes on running but can no longer answer. Microsoft’s HTTP trigger reference describes the limit. Inngest calls the endpoint once for each step, or once for several steps with checkpointing, so the limit is on one call, not on a run, which can last days. A call that hits it is a failed attempt, and Inngest retries the step, which starts again from its beginning.

Choose by how long your longest step is:

  • Steps well under the limit. Nothing to do. Split long work into more steps.
  • Many short steps. Checkpointing runs consecutive steps in one call and saves each to Inngest as it completes, which saves round trips. MaxRuntime is how long a call may keep starting steps, 10 seconds when you leave it out. A call lasts up to MaxRuntime plus the step it is running, so keep the two together well below 230 seconds.
  • A step that takes longer than the limit. A plain response cannot outlast it. Use streaming where the response is really streamed (below), or move the work out of the request: start it from the step on a queue-triggered function or another worker, and let the run wait for an event that the worker sends when it is done. Connect suits a worker that stays up, such as a container; a plan that scales to zero does not keep the connection Connect needs.

Checkpointing is set for the whole app, or for one function with FunctionOptions.Checkpointing:

options.Checkpointing = new CheckpointingOptions { MaxRuntime = TimeSpan.FromSeconds(30) };

With streaming on, a call still running after three seconds is answered with a 201 at once, then with a space every three seconds, then with its result, which keeps traffic on the connection (see Behind a proxy):

options.Streaming = true;

That only helps when the bytes reach Inngest as they are written. With the ASP.NET Core integration they leave the worker as they are written: the host forwards the request to the worker’s Kestrel with YARP’s forwarder, which passes the response on as it arrives, and the SDK answers through that request, as MapInngest does. Microsoft’s announcement of the integration names streaming content types among what it enables. With the built-in model they do not: the worker keeps the response until the function returns and then hands it to the host, so a long call stays silent. The SDK still answers normally there, and logs a warning, once per app, if Streaming is on.

Whether streaming carries a call past the 230 seconds on your plan has not been tested. Microsoft words the limit as the function not completing in time, and its App Service article calls it the load balancer’s idle timeout. Whether the spaces count as activity there is for you to test, with a step longer than the limit, before you rely on it.

Built-in ASP.NET Core integration
Registration ConfigureFunctionsWorkerDefaults ConfigureFunctionsWebApplication, with the Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore package
The response Kept by the worker until the function returns Written to the request while the function runs
Streaming No effect Works as in any ASP.NET Core app
The request body Received whole by the host; a chunked request without a Content-Length arrives empty Read from the request as it arrives

Use HttpRequestData and HttpResponseData, as above, in both. In the ASP.NET Core integration the SDK writes the answer to the response of the request itself, and the HttpResponseData the function returns is the integration’s wrapper of that response with nothing added to it.

Inngest’s calls and syncs declare the length of their body, so the built-in model’s rule about the body does not affect them. Behind a proxy that re-chunks requests, a call would arrive empty and the SDK would answer it with a 401.

  • The in-process model. The package serves the isolated worker only.
  • Streaming in the built-in model, for the reason above.
  • A function key. Inngest sends none, so the function stays anonymous; the signature check is what protects it in cloud mode. In dev mode nothing is verified, so keep dev mode on your machine.
  • Native AOT. Microsoft’s isolated worker guide documents ReadyToRun, not Native AOT, and the worker’s libraries are not marked as trimming or AOT compatible. The code of Inngest.AzureFunctions is trim and AOT safe, so it adds no warnings to an app that trims. For a web app published as a Native AOT binary, see Native AOT.
  • .NET Framework. The package targets net8.0 and net10.0, like the others.

The package is tested against fakes of the worker’s request, response and function context, written from the worker’s source, and against the Inngest server in Docker: a self-hosted server that signs its requests, for syncs, signed calls and signed answers through the built-in model, and the dev server, for streaming through the ASP.NET Core integration. In those tests an app stands in for the Functions host. It has not been run in a deployed function app, so how the host and the load balancer behave, as described above, comes from Microsoft’s documentation and from the source of the Functions host and worker.

inngest-dotnet is a community Inngest SDK for .NET. It is not affiliated with or endorsed by Inngest Inc.