# inngest-dotnet > inngest-dotnet is a community Inngest SDK for .NET. It runs durable functions and background workflows written in C#: steps that run once and retry on their own, sleeps and waits for events that last days, parallel steps, and flow control such as concurrency, throttling and rate limits. Functions are served from an ASP.NET Core endpoint, from Azure Functions or AWS Lambda, or from a Connect worker. Durable endpoints, in beta, let an ASP.NET Core endpoint run steps and answer its caller directly. It is not affiliated with or endorsed by Inngest Inc. The API is not the TypeScript SDK's. Steps are methods on `context.Step`, such as `RunAsync` and `SleepAsync`, a function is a class or a delegate, and an event's data is a record. Read [Writing Inngest .NET code with AI coding assistants](https://damikun.github.io/inngest-dotnet/guides/ai-assistants.md) before writing code with it. This file holds the Markdown of every page of the documentation, in the order of [llms.txt](https://damikun.github.io/inngest-dotnet/llms.txt). Each page starts with its title and the address of its web page. --- # Inngest .NET SDK for durable functions and workflows > inngest-dotnet is an Inngest SDK for .NET. Write durable functions and background workflows in C# with steps that retry, sleep and wait for events, served from ASP.NET Core, Azure Functions, AWS Lambda or a Connect worker. Write Inngest functions as plain C# classes in your ASP.NET Core app, with realtime updates and the .NET ecosystem you already use. Inngest runs them; this SDK connects your .NET app to it. Web page: https://damikun.github.io/inngest-dotnet/ ## The .NET side of Inngest Inngest is the platform that runs your functions: it receives events, keeps the state of every run, retries what fails and shows each run in its dashboard. This SDK is how a .NET app takes part. You write the functions in C#, and the SDK serves them to Inngest and sends your events. Inngest's own documentation covers the platform. - [Inngest concepts](https://www.inngest.com/docs/durable-execution/concepts): Events, triggers, runs, steps and waits, the terms these guides use, explained by Inngest. - [How Inngest functions run](https://www.inngest.com/docs/durable-execution/durable-workflows): How a run saves each step and resumes after a failure or a pause, the same for every SDK. - [The Inngest dev server](https://www.inngest.com/docs/local-development): Run Inngest on your machine and watch your .NET functions run step by step in its UI. [How it works](https://damikun.github.io/inngest-dotnet/how-it-works.md) shows what this SDK answers to each of Inngest's calls in a real run. ## Ordinary C#, durable by default The function from the top of the page, in full. Each step is a call to RunAsync, SleepAsync or another step tool; the rest is plain C#. ```csharp title="CheckoutFunction.cs" using Inngest; public sealed record OrderPaid(string OrderId, string CustomerId, decimal Amount); public sealed record Charge(string Id, decimal Amount); public sealed class CheckoutFunction(IPayments pay, IMailer mail, IWarehouse warehouse) : InngestFunction { public override async Task ExecuteAsync( FunctionContext context, CancellationToken cancellationToken) { var order = context.Event.Data; var charge = await context.Step.RunAsync("charge", ct => pay.ChargeAsync(order, ct)); await context.Step.RunAsync("send-receipt", ct => mail.SendReceiptAsync(order, ct)); await context.Step.SleepAsync("cooling-off", TimeSpan.FromMinutes(30)); await context.Step.RunAsync("ship", ct => warehouse.ShipAsync(order, ct)); return new { charge.Id }; } } public interface IPayments { Task ChargeAsync(OrderPaid order, CancellationToken cancellationToken); } public interface IMailer { Task SendReceiptAsync(OrderPaid order, CancellationToken cancellationToken); } public interface IWarehouse { Task ShipAsync(OrderPaid order, CancellationToken cancellationToken); } ``` **`RunAsync("charge", ...)`** is a step. Inngest stores the charge it returns, and later calls get that stored result instead of charging again. If it throws, Inngest retries this step alone. **`RunAsync("send-receipt", ...)`** is a step of its own. If the mail server fails, Inngest retries the receipt, and the charge stays done. **`SleepAsync("cooling-off", ...)`** ends the call. Inngest calls the function again in 30 minutes. Nothing runs or waits in your app meanwhile, and deploys in between do not matter. **`RunAsync("ship", ...)`** runs after the sleep. By then the charge and the receipt come back from Inngest's store, not from the payment provider or the mail server. **`return`** gives the run its output. The SDK answers `200` and Inngest marks the run completed. ## Everything the SDK can do Steps and flow control, realtime and AI, hosting, integrations and testing. Each card shows the main call and links to its guide. ### Write workflows #### Steps that run once A step's result is saved, so a later call gets it back without running the code again. ```csharp var paymentId = await context.Step.RunAsync( "charge", ct => payments.ChargeAsync( order.CustomerId, order.Total, ct)); ``` [Steps guide](https://damikun.github.io/inngest-dotnet/guides/steps.md) #### Sleep and wait for events Pause for days, or until an event arrives, without holding a thread or a worker. ```csharp var activated = await context.Step .WaitForEventAsync( "activation", TimeSpan.FromDays(3)); ``` [Sleep and wait guide](https://damikun.github.io/inngest-dotnet/guides/sleep-and-wait-for-events.md) #### Parallel steps Start steps together and await them with `Task.WhenAll`; Inngest runs each in its own call. ```csharp await Task.WhenAll(userIds.Select(userId => context.Step.RunAsync($"load-{userId}", ct => profiles.LoadAsync(userId, ct)))); ``` [Parallel steps guide](https://damikun.github.io/inngest-dotnet/guides/parallel-steps.md) #### Fan-out over a list Run a step for each item of a list, with a limit on how many run at once and the results back in order. ```csharp await context.Step.MapAsync("charge", due, (i, ct) => billing.ChargeAsync(i.Id, ct), new FanOutOptions { MaxParallelism = 5 }); ``` [Fan-out guide](https://damikun.github.io/inngest-dotnet/guides/fan-out.md) #### Invoke other functions Run another function as a step and get its typed result back, with its own retries and history. ```csharp var score = await context.Step .InvokeAsync( "score-acme", new ScoreRequest("acme")); ``` [Invoking functions guide](https://damikun.github.io/inngest-dotnet/guides/invoking-functions.md) #### Scoped steps Run a step in a dependency injection scope of its own, so a `DbContext` carries no state from one step to the next. ```csharp await context.Step .RunScopedAsync("reserve", (inv, ct) => inv.ReserveAsync(id, ct)); ``` [Scoped steps guide](https://damikun.github.io/inngest-dotnet/guides/scoped-steps.md) #### Event and cron triggers Start a function from a typed event, a filtered event or a cron schedule, with its options as attributes or in code. ```csharp [EventTrigger( If = "event.data.total >= 500")] [CronTrigger("0 6 * * 1-5", Timezone = "UTC")] ``` [Functions and triggers guide](https://damikun.github.io/inngest-dotnet/guides/functions-and-triggers.md) #### Flow control Concurrency, throttling, rate limits, debounce, batching, priority and singletons are attributes on the function. ```csharp [Throttle(10, "1m")] [Concurrency(1, Key = "event.data.accountId")] [Debounce("30s", Key = "event.data.accountId")] ``` [Flow control guide](https://damikun.github.io/inngest-dotnet/guides/flow-control.md) #### Errors and retries Every step retries on its own. Exceptions stop the retries or ask for a delay. ```csharp throw new RetryAfterException( "Rate limited.", TimeSpan.FromMinutes(1)); ``` [Errors and retries guide](https://damikun.github.io/inngest-dotnet/guides/errors-and-retries.md) ### Realtime and AI #### Realtime Publish updates and streams of text from a run or from any code, and relay them to browsers or receive them in .NET. ```csharp var status = new RealtimeTopic( $"order:{orderId}", "status"); await realtime.PublishAsync(status, "Charged"); ``` [Realtime guide](https://damikun.github.io/inngest-dotnet/guides/realtime.md) #### AI chat clients Run the model calls and tool calls of an `IChatClient` as steps, so a retry or a replay never repeats them. ```csharp using Microsoft.Extensions.AI; builder.UseInngestFunctionInvocation() .UseInngestSteps(); ``` [AI chat clients guide](https://damikun.github.io/inngest-dotnet/guides/ai.md) #### Blazor Show realtime messages in Blazor Server, WebAssembly and interactive Auto with the `RealtimeView` component. ```csharp services.AddInngest( options => options.AppId = "shop"); services.AddInngestRealtimeServerSource(); ``` [Blazor guide](https://damikun.github.io/inngest-dotnet/guides/blazor.md) #### SignalR Forward realtime messages to the clients of a SignalR hub, and decide who may join which channel. ```csharp services.AddInngestSignalR(options => options.Authorize = authorize); app.MapInngestRealtimeHub("/hubs/inngest"); ``` [SignalR guide](https://damikun.github.io/inngest-dotnet/guides/signalr.md) ### Run it anywhere #### Workers without an endpoint With Connect, a worker holds an outbound WebSocket to Inngest and needs no public URL. ```csharp inngest.AddConnect(connect => connect.MaxWorkerConcurrency = 20); ``` [Connect guide](https://damikun.github.io/inngest-dotnet/guides/connect.md) #### Durable endpoints Experimental Run steps in an ASP.NET Core endpoint, answer the caller directly, and hand the run to Inngest when a step has to wait or fails. Inngest calls it beta, so the API may still change. ```csharp app.UseInngestDurableEndpoints(); app.MapPost("/pay", (IStepTools step) => step.RunAsync("charge", () => "paid")) .WithDurableEndpoint(); ``` [Durable endpoints guide](https://damikun.github.io/inngest-dotnet/guides/durable-endpoints.md) #### Azure Functions Serve the endpoint from an HTTP-triggered function of an Azure Functions app in the isolated worker model. ```csharp using Microsoft.Azure.Functions.Worker.Http; // In an HTTP-triggered function return await request.ServeInngestAsync(); ``` [Azure Functions guide](https://damikun.github.io/inngest-dotnet/guides/azure-functions.md) #### AWS Lambda Serve the endpoint from a Lambda function behind API Gateway or a function URL, as a managed function or a Native AOT executable. ```csharp var handler = new InngestLambdaHandler(sp); return await handler.HandleAsync( request, context); ``` [AWS Lambda guide](https://damikun.github.io/inngest-dotnet/guides/aws-lambda.md) #### Native AOT Publish trimmed or as a Native AOT binary by giving the SDK source-generated JSON metadata for your types. ```csharp using System.Text.Json.Serialization; [JsonSerializable(typeof(OrderPlaced))] partial class OrderJson : JsonSerializerContext; ``` [Native AOT guide](https://damikun.github.io/inngest-dotnet/guides/native-aot.md) ### Integrations #### Send events Send events from application code with `IInngestClient` or from a function as a step, with ids for deduplication and delayed starts. ```csharp await inngest.SendAsync( new OrderPlaced("o-1001", "c-7", 49.90m)); ``` [Sending events guide](https://damikun.github.io/inngest-dotnet/guides/sending-events.md) #### Webhooks Verify the signature of a webhook from Stripe, GitHub, Shopify, Clerk or Resend, and send it as an event whose id is the delivery id. ```csharp app.MapInngestWebhook("/webhooks/stripe", WebhookProvider.Stripe(secret)); ``` [Webhooks guide](https://damikun.github.io/inngest-dotnet/guides/webhooks.md) #### Transactional outbox Save an event in the same Entity Framework Core transaction as your data, and a hosted dispatcher sends it to Inngest afterwards. ```csharp using Microsoft.EntityFrameworkCore; db.AddInngestEvent(placed); await db.SaveChangesAsync(cancellationToken); ``` [Transactional outbox guide](https://damikun.github.io/inngest-dotnet/guides/outbox.md) #### User context Carry the signed-in user who caused a run into it, as `ICurrentUser` or a `ClaimsPrincipal`, protected with ASP.NET Core Data Protection. ```csharp inngest.AddUserContext(options => options.Claims = ["sub", "role"]); inngest.AddUserContextFromHttpContext(); ``` [User context guide](https://damikun.github.io/inngest-dotnet/guides/user-context.md) #### Middleware Observe and wrap function runs, steps and event sends, for logging, metrics, tracing and error mapping. ```csharp inngest.AddMiddleware(); ``` [Middleware guide](https://damikun.github.io/inngest-dotnet/guides/middleware.md) #### Encryption Encrypt event data, step results and function output, in the format of the TypeScript SDK's encryption middleware. ```csharp inngest.AddEncryption(options => options.Key = encryptionKey); ``` [Encryption guide](https://damikun.github.io/inngest-dotnet/guides/middleware.md#encryption) #### Sentry Report the errors of functions and steps to Sentry, tagged with the app, the function, the run and the event. ```csharp builder.WebHost.UseSentry(o => o.Dsn = dsn); inngest.AddSentryReporting(); ``` [Sentry guide](https://damikun.github.io/inngest-dotnet/guides/middleware.md#sentry) #### OpenTelemetry Trace every call and step, and measure runs, retries and durations. ```csharp using OpenTelemetry.Trace; inngest.AddInstrumentation(); tracing.AddInngestInstrumentation(); ``` [OpenTelemetry reference](https://damikun.github.io/inngest-dotnet/reference/api.md#opentelemetry) ### Test and ship #### Testing Run a function in memory on the real execution engine with mocked steps, or against a fake `IStepTools`. ```csharp using Inngest.Testing; var mock = new StepMocks().Run("charge", "ok"); var run = await host.RunAsync("pay", e, mock); ``` [Testing guide](https://damikun.github.io/inngest-dotnet/guides/testing.md) #### Testing with containers Start the Inngest dev server or a self-hosted server in Docker from your tests, and run your app against it. ```csharp using Inngest.Testing.Containers; var server = new InngestServerBuilder( "inngest/inngest:v1.49.0").Build(); ``` [Testing with containers guide](https://damikun.github.io/inngest-dotnet/guides/testing-with-containers.md) #### Aspire Run the Inngest dev server as a container of your AppHost, with its UI linked in the Aspire dashboard. ```csharp var inngest = builder.AddInngest("inngest"); builder.AddProject("shop") .WithReference(inngest).WaitFor(inngest); ``` [Aspire guide](https://damikun.github.io/inngest-dotnet/guides/aspire.md) #### Source generator and analyzers Turn on a generator that registers every function class of a project with one call, and find the types a Native AOT app has not listed. ```csharp inngest.AddInngestFunctions(); ``` [Source generator guide](https://damikun.github.io/inngest-dotnet/guides/source-generator.md) #### Templates and the dotnet inngest tool Start an app with `dotnet new inngest-web` or `inngest-worker`, and run the Inngest dev server for it from its folder with `dotnet inngest dev`. ```bash dotnet new inngest-web -n Shop dotnet tool install --global dotnet-inngest dotnet inngest dev ``` [Templates and tool guide](https://damikun.github.io/inngest-dotnet/guides/templates-and-tools.md) ## Tested against the real Inngest server Triggers, steps, sleeps, waits, flow control, retries, middleware, key rotation and Connect run end to end against Inngest's own server image, in dev mode and in signed cloud mode. ## Your first durable function in five minutes Install the SDK, write a function and register it, start the dev server, and watch a run go step by step on your machine. 1. Install the SDK from NuGet. `Inngest.AspNetCore` brings in `Inngest`: ```bash dotnet add package Inngest.AspNetCore ``` 2. Write the function, a class with one method. Its attributes give it an id and name the event that starts it. Each `RunAsync` call is a step: it runs once and Inngest saves its result. `SleepAsync` pauses the run without holding a thread: ```csharp title="OnboardUser.cs" using Inngest; public sealed record UserSignedUp(string UserId, string Email); [InngestFunction("onboard-user")] [EventTrigger("user/signed-up")] public sealed class OnboardUser : InngestFunction { public override async Task ExecuteAsync( FunctionContext context, CancellationToken cancellationToken) { var user = context.Event.Data; await context.Step.RunAsync("send-welcome-email", () => Console.WriteLine($"Sending a welcome email to {user.Email}")); await context.Step.SleepAsync("wait-a-moment", TimeSpan.FromSeconds(10)); await context.Step.RunAsync("send-tips", () => Console.WriteLine($"Sending tips to {user.Email}")); return new { user.UserId }; } } ``` 3. Register the function and map the endpoint Inngest calls: ```csharp title="Program.cs" using Inngest; var builder = WebApplication.CreateBuilder(args); builder.Services .AddInngest(options => options.AppId = "shop") .AddFunction(); var app = builder.Build(); app.MapInngest("/api/inngest"); app.Run(); ``` 4. Set `INNGEST_DEV=1`, which points the SDK at the local dev server, and start the app with `dotnet run --urls http://localhost:5280`. Then run the [Inngest dev server](https://www.inngest.com/docs/local-development): **Install script** ```bash curl -sSfL https://cli.inngest.com/install.sh | sh inngest dev -u http://localhost:5280/api/inngest ``` **Homebrew** ```bash brew install inngest/tap/inngest inngest dev -u http://localhost:5280/api/inngest ``` **npx** ```bash npx --ignore-scripts=false inngest-cli@latest dev -u http://localhost:5280/api/inngest ``` **.NET tool** ```bash dotnet tool install --global dotnet-inngest dotnet inngest dev -u http://localhost:5280/api/inngest ``` The tool downloads the Inngest CLI once, checks its SHA-256 and runs it. In a project with a launch profile, leave out `-u`: the tool reads the address from `Properties/launchSettings.json`. **Docker** ```bash docker run --rm -p 8288:8288 -p 8289:8289 inngest/inngest:v1.49.0 inngest dev -u http://host.docker.internal:5280/api/inngest ``` The container reaches the app at `host.docker.internal`, since `localhost` inside it is the container itself. On Linux without Docker Desktop, add `--add-host=host.docker.internal:host-gateway`. **Compose** ```yaml title="compose.yaml" services: inngest: image: inngest/inngest:v1.49.0 command: inngest dev -u http://host.docker.internal:5280/api/inngest ports: - "8288:8288" - "8289:8289" extra_hosts: - "host.docker.internal:host-gateway" ``` ```bash docker compose up ``` 5. Send a `user/signed-up` event. The dev server takes any event key in the URL. The run appears at `http://localhost:8288`, and the app prints the welcome line, then the tips 10 seconds later: ```bash curl -X POST http://localhost:8288/e/test -d '{"name": "user/signed-up", "data": {"userId": "u1", "email": "ada@example.com"}}' ``` [Getting started](https://damikun.github.io/inngest-dotnet/guides/getting-started.md) goes through the same steps in detail. ## Questions engineers ask first ### Is this an official Inngest SDK? No. inngest-dotnet is a community SDK and is not affiliated with Inngest Inc. It speaks the same protocol as the official Inngest SDKs, and its end-to-end tests run against the Inngest server in dev and cloud mode. ### What do I need to run it? .NET 8 or .NET 10 and an Inngest server: Inngest Cloud, a [self-hosted Inngest](https://www.inngest.com/docs/platform-and-operations/self-host-inngest), or the dev server on your machine. Functions are served from an ASP.NET Core endpoint, from Azure Functions or AWS Lambda, or from a [Connect worker](https://damikun.github.io/inngest-dotnet/guides/connect.md). ### Does Inngest have to reach my app over the internet? With `MapInngest`, Inngest calls a public HTTPS endpoint in your app. With [Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md), the app opens a WebSocket to Inngest instead and needs no inbound port. ### Is it ready for production? The serve protocol, steps, flow control, signing, middleware and Connect are implemented and verified: 3,735 unit tests, 351 end-to-end tests against the Inngest server in Docker, and load and memory checks in dev and cloud mode. The [readiness report](https://damikun.github.io/inngest-dotnet/internals/readiness.md) sums them up. The packages are on NuGet as 1.0.0. Durable endpoints are the one feature still marked experimental. ### Does it work with Native AOT? Yes. The SDK uses source-generated JSON for its own types; add a `JsonSerializerContext` for yours, as described in [Native AOT and trimming](https://damikun.github.io/inngest-dotnet/guides/native-aot.md). ## Docs for this SDK and for Inngest - [Guides](https://damikun.github.io/inngest-dotnet/guides/getting-started.md): Functions, steps, events, flow control, errors, middleware, Connect and deployment. - [Examples](https://damikun.github.io/inngest-dotnet/examples/order-fulfillment.md): Order fulfillment, onboarding, nightly billing, webhooks and more, complete and explained. - [Roadmap](https://damikun.github.io/inngest-dotnet/roadmap.md): What each release added, from the first preview to 1.0.0, and what comes next: guides, checkpointing by default and durable endpoints out of experimental. - [Inngest documentation](https://www.inngest.com/docs): The platform: events, flow control, deployment, self-hosting and the dashboard. - [AI coding assistants](https://damikun.github.io/inngest-dotnet/guides/ai-assistants.md): The names that differ from the TypeScript SDK and the rules that keep a function correct when it replays. For tools that read the docs there are [`llms.txt`](https://damikun.github.io/inngest-dotnet/llms.txt) and [`llms-full.txt`](https://damikun.github.io/inngest-dotnet/llms-full.txt), and every page has a Markdown copy at its address with `.md` in place of the trailing slash. --- # How Inngest runs .NET functions > How durable execution works with the Inngest .NET SDK. Inngest calls your ASP.NET Core endpoint once per step, the function replays saved step results and answers 206 or 200. Step through a real run. Web page: https://damikun.github.io/inngest-dotnet/how-it-works/ Inngest runs a durable function by calling your app once per step. Every call runs the function from the top, and the steps that already finished return their saved results, so the function picks up where it stopped. One run of a checkout function, call by call: 1. Nothing is saved yet, so `charge` runs. The app answers 206 with its result, and Inngest saves it. 2. `charge` returns its saved result at once, without charging again, and `send-receipt` runs. 3. Both finished steps return their saved results. The sleep is new: the app reports it, and Inngest calls again in 30 minutes. 4. Thirty minutes later, every finished step returns its saved result, and `ship` runs. 5. Every step returns its saved result, the function returns, and the app answers 200. The run is complete. ## The loop 1. **Inngest calls your app.** It sends a request to `/api/inngest`, or to a [Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md) worker, with the saved result of every finished step. 2. **The function runs from the top.** A step that finished in an earlier call returns its saved result at once. It does not run again. 3. **The next step runs.** The first step without a saved result runs, or the run reports a sleep or a wait. 4. **The app answers.** `206` with the step's result, which Inngest saves before it calls again, now or when the sleep is over. `200` with the output once the function returns. ## Who does what Your code sends the events, Inngest stores the run and decides when to call, and the app keeps no state between calls, so any instance can take any call. ## Step through a real run One run of `FulfillOrder`, call by call: what Inngest sent, which steps had saved results, and what the SDK answered. The answers are what the SDK in this repository returns; the site's snippet check fails if they ever stop matching. ```csharp public sealed class FulfillOrder( IInventory inventory, IPayments payments, IMailer mailer, IWarehouse warehouse) : InngestFunction { public override async Task ExecuteAsync( FunctionContext context, CancellationToken cancellationToken) { var order = context.Event.Data; var reservation = await context.Step.RunAsync("reserve-stock", ct => inventory.ReserveAsync(order.OrderId, ct)); var payment = await context.Step.RunAsync("charge-card", ct => payments.ChargeAsync(order.CustomerId, order.Total, ct)); await context.Step.SleepAsync("cooling-off", TimeSpan.FromMinutes(30)); await Task.WhenAll( context.Step.RunAsync("send-receipt", ct => mailer.SendReceiptAsync(order.OrderId, payment.PaymentId, ct)), context.Step.RunAsync("ship-order", ct => warehouse.ShipAsync(reservation.ReservationId, ct))); return new OrderFulfilled(order.OrderId, payment.PaymentId); } } ``` | Call | Inngest sends | The SDK answers | | - | - | - | | 1 | no saved results | `206` with a `StepRun` op for `reserve-stock` | | 2 | the saved results of `reserve-stock` | `206` with a `StepRun` op for `charge-card` | | 3 | the saved results of `reserve-stock`, `charge-card` | `206` with a `Sleep` op for `cooling-off` | | 4 | the saved results of `reserve-stock`, `charge-card`, `cooling-off` | `206` with a `StepPlanned` op for `send-receipt` and a `StepPlanned` op for `ship-order` | | 5 | the saved results of `reserve-stock`, `charge-card`, `cooling-off` and a request to run `send-receipt` | `206` with a `StepRun` op for `send-receipt` | | 6 | the saved results of `reserve-stock`, `charge-card`, `cooling-off` and a request to run `ship-order` | `206` with a `StepRun` op for `ship-order` | | 7 | the saved results of `reserve-stock`, `charge-card`, `cooling-off`, `send-receipt`, `ship-order` | `200` with the output `{"orderId":"ord_1042","paymentId":"pay_31d9"}` | An interactive version is on [the web page](https://damikun.github.io/inngest-dotnet/how-it-works/). ## What the SDK answers | The function | The SDK answers | | - | - | | reaches one new `RunAsync` step | runs it and answers `206` with a `StepRun` op and its result | | starts several new steps together | runs none and answers `206` with a `StepPlanned` op for each; Inngest runs each in its own call | | reaches a sleep, a wait for an event or an invocation | answers `206` with that op; Inngest carries it out | | returns | answers `200` with the output | | throws | answers `500` if Inngest should retry, `400` if the failure is final | With [streaming](https://damikun.github.io/inngest-dotnet/guides/configuration.md#behind-a-proxy) on, a call that runs longer than three seconds is answered with a `201` at once, and the same answer follows inside the stream when the call ends. The [design document](https://damikun.github.io/inngest-dotnet/internals/design.md#protocol) lists every field. ## What it means for your code - **Code outside steps runs on every call.** Side effects and anything that differs between calls, such as the clock or a new id, belong in a step. See [Steps](https://damikun.github.io/inngest-dotnet/guides/steps.md). - **Step ids identify results.** Keep them the same while runs are in flight. - **Results are JSON.** Return types that round-trip through `System.Text.Json`, such as records. - **Nothing waits in your app.** A week-long sleep is a timestamp in Inngest; deploys and restarts do not affect it. ## Going deeper - [Parallel steps](https://damikun.github.io/inngest-dotnet/guides/parallel-steps.md) explains the planned steps in call 4 of the run above. - [Errors and retries](https://damikun.github.io/inngest-dotnet/guides/errors-and-retries.md) covers what happens when a step throws. - Inngest's [durable execution concepts](https://www.inngest.com/docs/durable-execution/concepts) describe the model for all of its SDKs. - The [order fulfillment example](https://damikun.github.io/inngest-dotnet/examples/order-fulfillment.md) is a production-style version of the function above. --- # Getting started with Inngest in ASP.NET Core > Add the Inngest .NET SDK to an ASP.NET Core app, write a first durable function in C#, run it against the Inngest dev server and watch each step run. Web page: https://damikun.github.io/inngest-dotnet/guides/getting-started/ In about five minutes you will have an ASP.NET Core app with a durable function running on your machine. When a user signs up, it sends a welcome email, waits, and sends a second one; if the second email fails, a retry sends only that one. The dev server shows every step as it happens. Figure: What you will run: the app sends an event when a user signs up, and the dev server runs the function by calling the app's endpoint once per step. Its UI shows every run. > **You need** > > - The [.NET SDK](https://dotnet.microsoft.com/download), version 8 or 10. > - A way to run the [Inngest dev server](https://www.inngest.com/docs/local-development): its install script, > Homebrew, Node.js or Docker. > **Start from a template** > > `dotnet new install Inngest.Templates` and `dotnet new inngest-web -n Shop` make a project like the one in this guide, > and `dotnet inngest dev` runs the dev server for it. See [Templates and tool](https://damikun.github.io/inngest-dotnet/guides/templates-and-tools.md). ## 1. Add the SDK Install `Inngest.AspNetCore` from NuGet. It brings in `Inngest`: ```bash dotnet add package Inngest.AspNetCore ``` The SDK is split into these packages: | Package | What it gives you | | - | - | | [`Inngest`](https://www.nuget.org/packages/Inngest/) | Functions, steps, triggers, flow control, middleware, the client that sends events, and request signing. A source generator that registers your function classes, which a project turns on, and an analyzer that finds the types a Native AOT app has not listed, come with it. See [Source generator and analyzers](https://damikun.github.io/inngest-dotnet/guides/source-generator.md). | | [`Inngest.AspNetCore`](https://www.nuget.org/packages/Inngest.AspNetCore/) | `MapInngest`, the endpoint Inngest calls in an ASP.NET Core app, and a health check for readiness probes. | | [`Inngest.AwsLambda`](https://www.nuget.org/packages/Inngest.AwsLambda/) | `InngestLambdaHandler`, which serves the endpoint from an AWS Lambda function behind API Gateway or a function URL. See [Running on AWS Lambda](https://damikun.github.io/inngest-dotnet/guides/aws-lambda.md). | | [`Inngest.AzureFunctions`](https://www.nuget.org/packages/Inngest.AzureFunctions/) | `ServeInngestAsync`, the endpoint Inngest calls, served from an HTTP-triggered function of an Azure Functions app. See [Azure Functions](https://damikun.github.io/inngest-dotnet/guides/azure-functions.md). | | [`Inngest.Blazor`](https://www.nuget.org/packages/Inngest.Blazor/) | A Blazor component that shows realtime messages on the server, in WebAssembly and in interactive Auto. See [Realtime in Blazor](https://damikun.github.io/inngest-dotnet/guides/blazor.md). | | [`Inngest.Connect`](https://www.nuget.org/packages/Inngest.Connect/) | `AddConnect`, a hosted worker that receives calls over an outbound WebSocket, with no endpoint to expose. See [Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md). | | [`Inngest.Aspire.Hosting`](https://www.nuget.org/packages/Inngest.Aspire.Hosting/) | An Aspire hosting integration for the AppHost project: runs the Inngest server as a container and gives projects the settings the SDK reads. See [Aspire](https://damikun.github.io/inngest-dotnet/guides/aspire.md). | | [`Inngest.Testing`](https://www.nuget.org/packages/Inngest.Testing/) | An in-memory test host that runs your functions on the real execution engine. See [Testing functions](https://damikun.github.io/inngest-dotnet/guides/testing.md). | | [`Inngest.Testing.Containers`](https://www.nuget.org/packages/Inngest.Testing.Containers/) | A Testcontainers module that runs the Inngest dev server or a self-hosted server in Docker for end-to-end tests. See [Testing with containers](https://damikun.github.io/inngest-dotnet/guides/testing-with-containers.md). | | [`Inngest.Testing.Containers.AspNetCore`](https://www.nuget.org/packages/Inngest.Testing.Containers.AspNetCore/) | A `WebApplicationFactory` that runs an ASP.NET Core app on a real port against that server (.NET 10). See [Testing with containers](https://damikun.github.io/inngest-dotnet/guides/testing-with-containers.md#an-app-under-webapplicationfactory). | | [`Inngest.OpenTelemetry`](https://www.nuget.org/packages/Inngest.OpenTelemetry/) | Traces and metrics for function calls and steps. | | [`Inngest.Encryption`](https://www.nuget.org/packages/Inngest.Encryption/) | End-to-end encryption of event data, step results and function output. See [Encryption](https://damikun.github.io/inngest-dotnet/guides/middleware.md#encryption). | | [`Inngest.Sentry`](https://www.nuget.org/packages/Inngest.Sentry/) | Reports the errors of functions and steps to Sentry. See [Middleware](https://damikun.github.io/inngest-dotnet/guides/middleware.md#sentry). | | [`Inngest.Extensions.AI`](https://www.nuget.org/packages/Inngest.Extensions.AI/) | Makes the model calls and tool calls of a Microsoft.Extensions.AI `IChatClient` steps of the run. See [AI chat clients](https://damikun.github.io/inngest-dotnet/guides/ai.md). | | [`Inngest.EntityFrameworkCore`](https://www.nuget.org/packages/Inngest.EntityFrameworkCore/) | A transactional outbox for Entity Framework Core: events saved with your data and sent to Inngest afterwards. See [Transactional outbox](https://damikun.github.io/inngest-dotnet/guides/outbox.md). | | [`Inngest.Templates`](https://www.nuget.org/packages/Inngest.Templates/) | `dotnet new inngest-web` and `dotnet new inngest-worker`, which make a project like the one below. See [Templates and tool](https://damikun.github.io/inngest-dotnet/guides/templates-and-tools.md). | | [`dotnet-inngest`](https://www.nuget.org/packages/dotnet-inngest/) | A .NET tool: `dotnet inngest dev` downloads the Inngest CLI and runs the dev server for the app. See [Templates and tool](https://damikun.github.io/inngest-dotnet/guides/templates-and-tools.md#the-dotnet-inngest-command). | | [`Inngest.SignalR`](https://www.nuget.org/packages/Inngest.SignalR/) | Forwards Inngest realtime messages to the clients of a SignalR hub. See [SignalR](https://damikun.github.io/inngest-dotnet/guides/signalr.md). | | [`Inngest.UserContext`](https://www.nuget.org/packages/Inngest.UserContext/) | Carries the signed-in user who caused a run into it, as `ICurrentUser`. See [User context](https://damikun.github.io/inngest-dotnet/guides/user-context.md). | | [`Inngest.UserContext.AspNetCore`](https://www.nuget.org/packages/Inngest.UserContext.AspNetCore/) | Takes the user an event is sent for from `HttpContext.User`. | | [`Inngest.Webhooks`](https://www.nuget.org/packages/Inngest.Webhooks/) | Verifies the signatures of webhooks from Stripe, GitHub, Shopify, Clerk, Resend and other providers and sends each as an event, without a provider SDK. `Inngest.AspNetCore` brings it in. See [Webhooks](https://damikun.github.io/inngest-dotnet/guides/webhooks.md). | ## 2. Write a function A function is a class. `[InngestFunction]` gives it an id and `[EventTrigger]` names the event that starts it. Each `RunAsync` is a step: it runs once and Inngest saves its result. `SleepAsync` pauses the run without holding a thread. ```csharp title="OnboardUser.cs" using Inngest; public sealed record UserSignedUp(string UserId, string Email); [InngestFunction("onboard-user")] [EventTrigger("user/signed-up")] public sealed class OnboardUser(IEmailSender email) : InngestFunction { public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) { var user = context.Event.Data; await context.Step.RunAsync("send-welcome-email", ct => email.SendAsync(user.Email, "Welcome!", ct)); await context.Step.SleepAsync("wait-before-tips", TimeSpan.FromSeconds(30)); await context.Step.RunAsync("send-tips", ct => email.SendAsync(user.Email, "Three things to try first", ct)); return new { user.UserId }; } } ``` The email service only logs for now: ```csharp title="EmailSender.cs" public interface IEmailSender { Task SendAsync(string to, string subject, CancellationToken cancellationToken); } public sealed class ConsoleEmailSender(ILogger logger) : IEmailSender { public Task SendAsync(string to, string subject, CancellationToken cancellationToken) { logger.LogInformation("Sending \"{Subject}\" to {To}", subject, to); return Task.CompletedTask; } } ``` ## 3. Serve it `AddInngest` registers the SDK and the function, `MapInngest` maps the endpoint Inngest calls, and `/signup` sends the event that starts a run: ```csharp title="Program.cs" using Inngest; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton(); builder.Services .AddInngest(options => options.AppId = "shop") .AddFunction(); var app = builder.Build(); app.MapInngest("/api/inngest"); app.MapPost("/signup", async (SignupRequest request, IInngestClient inngest, CancellationToken cancellationToken) => { var userId = Guid.NewGuid().ToString("N"); await inngest.SendAsync(new InngestEvent("user/signed-up", new UserSignedUp(userId, request.Email)), cancellationToken); return Results.Accepted(value: new { userId }); }); app.Run(); public sealed record SignupRequest(string Email); ``` The function's options are attributes on the class here. They can also be passed in code, as `FunctionOptions` given to `AddFunction`, which suits values the app reads from configuration; [Functions and triggers](https://damikun.github.io/inngest-dotnet/guides/functions-and-triggers.md#code-first-or-attributes) shows both forms. > **Tip** > > Keep the app id and the function id stable. Inngest knows this function as `shop-onboard-user`; renaming it > creates a new function. ## 4. Run it 1. Start the app in dev mode. `INNGEST_DEV=1` points the SDK at the local dev server: **bash** ```bash INNGEST_DEV=1 dotnet run --urls http://localhost:5280 ``` **PowerShell** ```powershell $env:INNGEST_DEV = "1"; dotnet run --urls http://localhost:5280 ``` 2. Start the dev server and point it at the app. Use the install script or Homebrew on macOS and Linux, npx when Node.js is installed, Docker when it is not, and Compose when a team shares the setup: **Install script** ```bash curl -sSfL https://cli.inngest.com/install.sh | sh inngest dev -u http://localhost:5280/api/inngest ``` **Homebrew** ```bash brew install inngest/tap/inngest inngest dev -u http://localhost:5280/api/inngest ``` **npx** ```bash npx --ignore-scripts=false inngest-cli@latest dev -u http://localhost:5280/api/inngest ``` **.NET tool** ```bash dotnet tool install --global dotnet-inngest dotnet inngest dev -u http://localhost:5280/api/inngest ``` The tool downloads the Inngest CLI once, checks its SHA-256 and runs it. In a project with a launch profile, leave out `-u`: the tool reads the address from `Properties/launchSettings.json`. **Docker** ```bash docker run --rm -p 8288:8288 -p 8289:8289 inngest/inngest:v1.49.0 inngest dev -u http://host.docker.internal:5280/api/inngest ``` The container reaches the app at `host.docker.internal`, since `localhost` inside it is the container itself. On Linux without Docker Desktop, add `--add-host=host.docker.internal:host-gateway`. **Compose** ```yaml title="compose.yaml" services: inngest: image: inngest/inngest:v1.49.0 command: inngest dev -u http://host.docker.internal:5280/api/inngest ports: - "8288:8288" - "8289:8289" extra_hosts: - "host.docker.internal:host-gateway" ``` ```bash docker compose up ``` 3. Sign up a user: ```bash curl -X POST http://localhost:5280/signup -H 'Content-Type: application/json' -d '{"email": "ada@example.com"}' ``` 4. Open `http://localhost:8288` and select **Runs**. The run shows `send-welcome-email`, the sleep, and after 30 seconds `send-tips`. ## What happened Inngest called the app four times for that one run, once per step and once more for the output: Figure: Every call runs `ExecuteAsync` from the top. Steps that finished earlier return their saved results at once, so the welcome email goes out once however many calls pass it. That is why side effects belong in steps and why step ids must not change while runs are in flight. [Steps](https://damikun.github.io/inngest-dotnet/guides/steps.md) explains both, and [How it works](https://damikun.github.io/inngest-dotnet/how-it-works.md) lets you step through a run. ## Next steps - [Functions and triggers](https://damikun.github.io/inngest-dotnet/guides/functions-and-triggers.md): Event filters, cron schedules and functions as delegates. - [Sleep and wait for events](https://damikun.github.io/inngest-dotnet/guides/sleep-and-wait-for-events.md): Pause a run until a time or until something happens. - [Deploying](https://damikun.github.io/inngest-dotnet/guides/deploying.md): Run the app against Inngest Cloud. - [The sample app](https://github.com/damikun/inngest-dotnet/tree/main/samples/Inngest.Sample): samples/Inngest.Sample: a complete app with several functions. --- # Project templates and the dotnet inngest command > Start an Inngest app with dotnet new inngest-web or inngest-worker, and run the Inngest dev server for it with dotnet inngest dev, which downloads the Inngest CLI, checks its SHA-256 and finds the app in its launch settings. Web page: https://damikun.github.io/inngest-dotnet/guides/templates-and-tools/ Two packages take the first steps of [getting started](https://damikun.github.io/inngest-dotnet/guides/getting-started.md) off your hands. [`Inngest.Templates`](https://www.nuget.org/packages/Inngest.Templates/) makes a project with a function that runs, and [`dotnet-inngest`](https://www.nuget.org/packages/dotnet-inngest/) is a .NET tool that runs the Inngest dev server for it. You can use either without the other. ## Templates Install the template package once. It adds two templates to `dotnet new`: ```bash dotnet new install Inngest.Templates ``` | Template | Makes | | - | - | | `inngest-web` | An ASP.NET Core app that serves an Inngest function, with an endpoint that sends the event that starts it. | | `inngest-worker` | A worker that runs the same function over [Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md), with no endpoint to expose. | Make a project and run it: ```bash dotnet new inngest-web -n Shop cd Shop dotnet run ``` The options of both templates: | Option | Meaning | Default | | - | - | - | | `-n`, `--name` | The name of the project. The app id is the same name in kebab case, such as `shop-orders` for `Shop.Orders`. | The name of the folder | | `-f`, `--framework` | `net10.0` or `net8.0`. | `net10.0` | | `--http-port` | The port in `Properties/launchSettings.json`. Only `inngest-web`. | A free port from 5000 to 5300 | | `--no-restore` | Does not restore the packages after making the project. | Restores | The default framework needs the .NET 10 SDK. With the .NET 8 SDK, make the project with `-f net8.0`. ### The web app `inngest-web` makes a project with an event, a function, a `Program.cs`, its settings and a launch profile. The event is a record that carries its own name: ```csharp title="Events.cs" using Inngest; // An event is a record that carries its own name, so sending it and triggering a function with it need no string. public sealed record HelloRequested(string Name) : IInngestEventData { public static string EventName => "app/hello.requested"; } ``` The function has one step. Its attributes give it an id and the event that starts it: ```csharp title="SayHello.cs" using Inngest; // The attributes give the function its id and the event that starts it. A step runs once and Inngest saves its result, // so a retry repeats only the step that failed. [InngestFunction("say-hello", Name = "Say hello")] [EventTrigger] public sealed class SayHello : InngestFunction { public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) { var greeting = await context.Step.RunAsync("compose-greeting", () => $"Hello, {context.Event.Data.Name}!"); context.Logger.LogInformation("{Greeting}", greeting); return new { greeting }; } } ``` `Program.cs` registers the function, maps the endpoint Inngest calls and has one endpoint of its own, which sends the event: ```csharp title="Program.cs" using Inngest; var builder = WebApplication.CreateBuilder(args); // The settings come from the Inngest section of appsettings.json. appsettings.Development.json turns on dev mode, which // talks to the Inngest dev server on this machine and needs no keys. builder.Services .AddInngest(builder.Configuration.GetSection("Inngest")) .AddFunction(); var app = builder.Build(); // Inngest calls this endpoint to sync the app's functions and to run their steps. app.MapInngest(); // Sends the event that starts SayHello: curl -X POST http://localhost:5280/hello/Ada app.MapPost("/hello/{name}", async (string name, IInngestClient inngest, CancellationToken cancellationToken) => { var sent = await inngest.SendAsync(new HelloRequested(name), cancellationToken); return Results.Accepted(value: new { eventId = sent.Ids[0] }); }); app.Run(); ``` The project references `Inngest.AspNetCore`. The settings put the app id in `appsettings.json` and dev mode in `appsettings.Development.json`, so that only the Development environment talks to a dev server: ```json title="appsettings.json" { "Inngest": { "AppId": "shop" } } ``` ```json title="appsettings.Development.json" { "Inngest": { "IsDev": true } } ``` The launch profile runs the app in the Development environment on the port the template picked, which is 5280 in these examples. The [dotnet inngest command](https://damikun.github.io/inngest-dotnet/guides/templates-and-tools.md#the-dotnet-inngest-command) reads the app's address from it: ```json title="Properties/launchSettings.json" { "profiles": { "http": { "commandName": "Project", "applicationUrl": "http://localhost:5280", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } } ``` Outside the Development environment the app runs in cloud mode, which needs the event key and the signing key of an Inngest app. See [Configuration](https://damikun.github.io/inngest-dotnet/guides/configuration.md) and [Deploying](https://damikun.github.io/inngest-dotnet/guides/deploying.md). ### The worker `inngest-worker` makes the same event and function, the same settings and a `Program.cs` that serves them over Connect instead of mapping an endpoint: ```csharp title="Program.cs" using Inngest; var builder = Host.CreateApplicationBuilder(args); // The settings come from the Inngest section of appsettings.json. appsettings.Development.json turns on dev mode, which // talks to the Inngest dev server on this machine and needs no keys. builder.Services .AddInngest(builder.Configuration.GetSection("Inngest")) .AddFunction() .AddConnect(); // The worker dials out to Inngest and receives its calls over that connection, so it has no endpoint to expose. await builder.Build().RunAsync(); ``` Its project uses the worker SDK and references `Inngest.Connect` and `Microsoft.Extensions.Hosting`. It has no endpoint, so there is no event endpoint either: send the event from the dev server's UI, or to its event API. ```bash curl -X POST http://localhost:8288/e/dev -d '{"name": "app/hello.requested", "data": {"name": "Ada"}}' ``` ### Versions A new project references the Inngest packages at the version of the template package that made it. `dotnet new update` updates the template package, so that the next project gets newer packages. The reference is an ordinary `PackageReference`: raise its version to update the SDK in a project that exists. `Microsoft.Extensions.Hosting` in the worker is the version the SDK's own tests run with. `dotnet new uninstall Inngest.Templates` removes the templates. ## The dotnet inngest command `dotnet-inngest` is a .NET tool that adds `dotnet inngest` to the command line. Its one command, `dev`, runs the [Inngest dev server](https://www.inngest.com/docs/local-development) without you installing it first: ### For you ```bash dotnet tool install --global dotnet-inngest ``` ### For a repository A local tool is recorded in the tool manifest, `dotnet-tools.json`, so everyone who clones the repository gets it with `dotnet tool restore`: ```bash dotnet new tool-manifest dotnet tool install dotnet-inngest ``` Start your app, then the dev server, each in a terminal of its own. From the folder of a project made by `inngest-web`: ```bash dotnet run ``` ```bash dotnet inngest dev ``` The first run downloads the Inngest CLI; the later ones start at once. The tool runs `inngest dev` and passes on its output, its exit code and Ctrl+C. Open `http://localhost:8288` to see the runs. ### Where the dev server finds the app `inngest dev` needs the address of the app's Inngest endpoint, which it is told with `-u`. Without `-u`, and without `--no-discovery`, the tool reads it from `Properties/launchSettings.json` in the current folder: the first profile that runs the project and has an `http` address supplies it, and the tool appends `/api/inngest`, the path of `MapInngest`. It says which one it used: ```text The dev server will call http://localhost:5280/api/inngest, from the profile http of Properties/launchSettings.json. -u names another. ``` An address that listens on every interface, such as `http://*:5280`, is called on `localhost`. Profiles with only `https` give nothing, because the dev server may not trust the development certificate. When no address is found, the dev server looks for apps on its own, on the ports it knows. ### Options | Option | Meaning | | - | - | | `-u`, `--sdk-url ` | The URL of the app's Inngest endpoint. Can be given more than once. | | `--project ` | The folder, or project file, whose launch settings give the address. The current folder by default. | | `--cli-version ` | Runs another release of the Inngest CLI, such as `1.50.0`. | | `-h`, `--help` | Shows the usage. | | `--version` | Shows the version of the tool and of the Inngest CLI it runs by default. Only on its own, as in `dotnet inngest --version`. | The tool owns `--project` and `--cli-version`. Every other option goes to `inngest dev` as it is and in the order it was given, so `dotnet inngest dev --port 9000 --persist` works, and so does an option of a later release of the CLI. Everything after `--` goes to the CLI untouched; `dotnet inngest dev -- --help` shows its options. For a Connect worker, which has no endpoint, pass `--no-discovery`, and the tool adds no address: ```bash dotnet inngest dev --no-discovery ``` ### What it downloads The tool runs the release of the Inngest CLI from [github.com/inngest/inngest](https://github.com/inngest/inngest/releases), the file the install script and the `inngest-cli` npm package download too. The file is chosen for the machine and not for the .NET process: under Rosetta on a Mac with Apple silicon, or on Windows on Arm, the native binary is the one that runs. | System | Architecture | File | | - | - | - | | Linux | x64, Arm64 | `inngest_1.49.0_linux_amd64.tar.gz`, `inngest_1.49.0_linux_arm64.tar.gz` | | macOS | x64, Arm64 | `inngest_1.49.0_darwin_amd64.tar.gz`, `inngest_1.49.0_darwin_arm64.tar.gz` | | Windows | x64, Arm64 | `inngest_1.49.0_windows_amd64.zip`, `inngest_1.49.0_windows_arm64.zip` | The release has no file for other systems, such as 32-bit ones. The tool is pinned to one version, 1.49.0 here, the one of the `inngest/inngest` image that this SDK's tests run against, and a new version of the tool moves the pin. Before it unpacks anything the tool checks the SHA-256 of the download: - **The pinned version** is checked against the checksums in the source of the tool, copied from the release's `checksums.txt`. A file that is replaced on GitHub fails the check, even when `checksums.txt` next to it is replaced too. - **Another version**, named with `--cli-version`, is checked against the `checksums.txt` of its release. That catches a damaged or cut-off download. It does not catch a replaced release, since the checksums come from the same place, and the tool says so when you ask for one. A download that does not match is deleted and nothing is installed. The tool then ends with exit code 1. The CLI is kept per user and per version, and a folder that holds a binary is used again without a request. The CLI is about 100 MB unpacked. | System | Where | | - | - | | Windows | `%LOCALAPPDATA%\inngest-dotnet\cli\\_` | | Linux, macOS | `~/.local/share/inngest-dotnet/cli//_`, or under `$XDG_DATA_HOME` | `INNGEST_DOTNET_CACHE` names another folder for the whole cache. Delete a version's folder to remove it. A machine without access to GitHub can use the cache as it is: put the binary of the release in the folder of its version and system, such as `1.49.0/linux_amd64`, as `inngest` (`inngest.exe` on Windows), and the tool runs it without a request. The tool checks a download once, when it installs it, and runs the binary it finds in the cache from then on. So the cache folder must be writable by you alone. The default is in your own profile. Do not point `INNGEST_DOTNET_CACHE` at a folder that other users can write to: one of them could put there the binary that the tool runs for you. ### Ctrl+C, exit codes and Docker The CLI runs on the console of the tool, so its output, and its input, are yours. Ctrl+C reaches the CLI, which shuts down; the tool waits for it and ends with its exit code. The tool itself ends with 2 for a command line it cannot read and with 1 when it cannot get the CLI. On Linux and macOS a `SIGINT` or `SIGTERM` sent to the tool alone is passed on to the CLI. Nothing else is: a tool that is killed with `SIGKILL`, or that crashes, leaves the CLI running. On Windows the tool passes on nothing and does not put the CLI in a job object, so a tool that is ended alone, from Task Manager for instance, leaves the CLI running too. Closing the terminal ends both. A dev server that is left behind keeps running until you stop it by its process id. When it cannot get the CLI, because the machine is offline or no release exists for it, the message ends with a command that runs the dev server in Docker instead, for the same address: ```text docker run --rm -p 8288:8288 -p 8289:8289 --add-host=host.docker.internal:host-gateway inngest/inngest:v1.49.0 inngest dev -u http://host.docker.internal:5280/api/inngest ``` The container reaches the app on the host as `host.docker.internal`, since `localhost` inside it is the container itself. It is not run for you; the tool runs the dev server as a process. > **What the tool does not do** > > It does not start your app, and it does not replace the Inngest CLI: it downloads the CLI once and passes your > arguments to it. The tool makes no request except that download, and the checksums of a release you name with > `--cli-version`, and it sends no telemetry. It updates the CLI only when you update the tool or name another version. > > The package holds no part of the CLI. The CLI is Inngest's software, under the license in the `LICENSE.md` of its > release, and this project is not affiliated with Inngest Inc. See [getting started](https://damikun.github.io/inngest-dotnet/guides/getting-started.md) for what happens once the app and the dev server run. --- # Writing Inngest .NET code with AI coding assistants > The names that differ from the TypeScript SDK and the rules that keep a function correct when it replays, for AI coding assistants and the people who use them, with a block for AGENTS.md, CLAUDE.md or Cursor rules. Web page: https://damikun.github.io/inngest-dotnet/guides/ai-assistants/ AI coding assistants have seen far more of the Inngest TypeScript SDK than of this one, so they tend to write inngest-js patterns in C#: methods that do not exist, and code that goes wrong when a run replays. This page lists the names that differ, what only the .NET SDK has, and the rules that keep a function correct. Point your assistant at it before it writes code with the SDK, or [paste the rules](https://damikun.github.io/inngest-dotnet/guides/ai-assistants.md#add-the-rules-to-your-repository) into your own repository's instructions. ## Give the assistant the docs The docs come in forms made for tools: - [`llms.txt`](https://damikun.github.io/inngest-dotnet/llms.txt) lists every page with a one-line description, in the llms.txt format. - [`llms-full.txt`](https://damikun.github.io/inngest-dotnet/llms-full.txt) holds every page in one file, for tools that take a single file. - Every page has a Markdown copy next to it, at its address with `.md` in place of the trailing slash. `https://damikun.github.io/inngest-dotnet/guides/steps/` has `https://damikun.github.io/inngest-dotnet/guides/steps.md`, and the home page has `index.md`. The footer of every page links to its copy. The copies are plain Markdown: code blocks keep their language, links are absolute and point at other copies, and the interactive tools are left out. ## Names The step tools are methods of `context.Step`, and they end in `Async`. A function is a class, or a delegate, not a callback given to `createFunction`, and an event's data is a record. | inngest-js | inngest-dotnet | | - | - | | `inngest.createFunction` | A class that derives from `InngestFunction` and overrides `ExecuteAsync`, with `[InngestFunction("id")]` and `[EventTrigger("name")]` on it, registered with `AddFunction()`. Or `AddFunction(FunctionOptions, handler)`. See [Functions and triggers](https://damikun.github.io/inngest-dotnet/guides/functions-and-triggers.md). | | `{ event }`, `{ cron }` and `if` triggers | `[EventTrigger("name")]` or `[EventTrigger]`, `[CronTrigger("0 6 * * 1-5", Timezone = "UTC")]`, and `If = "..."` on `[EventTrigger]`. See [Functions and triggers](https://damikun.github.io/inngest-dotnet/guides/functions-and-triggers.md). | | `concurrency`, `throttle`, `rateLimit`, `debounce`, `batchEvents`, `priority`, `singleton`, `cancelOn`, `timeouts` | Attributes on the class: `[Concurrency]`, `[Throttle]`, `[RateLimit]`, `[Debounce]`, `[BatchEvents]`, `[Priority]`, `[Singleton]`, `[CancelOn]` and `[Timeouts]`, or the same options in `FunctionOptions`. See [Flow control](https://damikun.github.io/inngest-dotnet/guides/flow-control.md). | | `retries`, `idempotency` | `Retries` and `Idempotency` on `[InngestFunction]`, or in `FunctionOptions`. | | `onFailure` | `IFailureHandler` on the function's class, with `OnFailureAsync(FunctionContext context, CancellationToken cancellationToken)`, or the `onFailure` argument of `AddFunction`. See [Errors and retries](https://damikun.github.io/inngest-dotnet/guides/errors-and-retries.md). | | `serve` | `AddInngest(...)` on the services, then `app.MapInngest("/api/inngest")`. `AddConnect()` instead for a worker with no endpoint; see [Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md). | | `checkpointing` | `Checkpointing = new CheckpointingOptions()` in the options of `AddInngest`, or in `FunctionOptions` for one function. It is off by default. | | `inngest.endpoint` | `app.UseInngestDurableEndpoints()`, then `[DurableEndpoint("id")]` on a controller action or `WithDurableEndpoint("id")` on a minimal API endpoint. The handler takes the step tools as an `IStepTools step` parameter, and there is no `context`. The API is experimental: suppress the diagnostic `INNGEST001`. See [Durable endpoints](https://damikun.github.io/inngest-dotnet/guides/durable-endpoints.md). | | `inngest.send` | `IInngestClient.SendAsync(new InngestEvent("name", data))`, or `SendAsync(record)` for a record that implements `IInngestEventData`. See [Sending events](https://damikun.github.io/inngest-dotnet/guides/sending-events.md). | | `step.run` | `context.Step.RunAsync(id, handler)` | | `step.sleep` | `SleepAsync(id, TimeSpan.FromHours(1))` | | `step.sleepUntil` | `SleepUntilAsync(id, DateTimeOffset)` | | `step.waitForEvent` | `WaitForEventAsync(id, new WaitForEventOptions(event, timeout) { Match = "data.userId" })`; returns `null` at the timeout. See [Sleep and wait for events](https://damikun.github.io/inngest-dotnet/guides/sleep-and-wait-for-events.md). | | `step.waitForSignal` | `WaitForSignalAsync(id, new WaitForSignalOptions(signal, timeout))`; returns `null` at the timeout. `SendSignalAsync` sends one. | | `step.invoke` | `InvokeAsync(id, new InvokeOptions(functionId) { Data = data })`, or `InvokeAsync(id, data)` for a function class of the app. See [Invoking functions](https://damikun.github.io/inngest-dotnet/guides/invoking-functions.md). | | `step.sendEvent` | `SendEventAsync(id, events)`: a list of `InngestEvent`, one event, or records. | | `step.fetch` | `FetchAsync(id, HttpRequestMessage)`; returns an `HttpResponseMessage`. | | `step.ai.infer` | `InferAsync(id, new InferOptions(AIFormat.OpenAIChat, url, body))` | | `step.ai.wrap` | `AIWrapAsync(id, input, handler)` | | `step.realtime.publish` | `PublishAsync(id, topic, data)`, with a `RealtimeTopic(channel, name)`. `PublishStreamAsync` streams text. See [Realtime](https://damikun.github.io/inngest-dotnet/guides/realtime.md). | | `Promise.all` over steps | Start the steps, then `await Task.WhenAll(...)`. See [Parallel steps](https://damikun.github.io/inngest-dotnet/guides/parallel-steps.md). | | `group.parallel` in race mode | `ParallelAsync(ParallelMode.Race, ...)`. See [Parallel steps](https://damikun.github.io/inngest-dotnet/guides/parallel-steps.md). | | `subscribe`, `getSubscriptionToken` | `SubscribeAsync` and `GetSubscriptionTokenAsync` of `IInngestRealtime`. See [Realtime](https://damikun.github.io/inngest-dotnet/guides/realtime.md). | | `NonRetriableError`, `RetryAfterError` | `NonRetriableException`, `RetryAfterException` | | `new InngestMiddleware(...)` | A class that derives from `InngestMiddleware`, registered with `AddMiddleware()` on the builder that `AddInngest` returns. See [Middleware](https://damikun.github.io/inngest-dotnet/guides/middleware.md). | | `@inngest/middleware-encryption`, `@inngest/middleware-sentry` | `AddEncryption(...)` from `Inngest.Encryption`, `AddSentryReporting()` from `Inngest.Sentry`. | | `@inngest/test` | `InngestTestHost` and `StepMocks` from `Inngest.Testing`, which run a function on the real execution engine. See [Testing](https://damikun.github.io/inngest-dotnet/guides/testing.md). | By default event data is serialized with camelCase names, so the property `OrderId` is `data.orderId` in the expressions of `Match`, `If` and the flow control options. [The API overview](https://damikun.github.io/inngest-dotnet/reference/api.md) lists every signature. A function, its registration and a send, in the .NET forms: ```csharp title="ChargeOrder.cs" using Inngest; [InngestFunction("charge-order")] [EventTrigger] public sealed class ChargeOrder(IPaymentGateway payments) : InngestFunction { public override async Task ExecuteAsync( FunctionContext context, CancellationToken cancellationToken) { var order = context.Event.Data; var paymentId = await context.Step.RunAsync("charge", ct => payments.ChargeAsync(order.CustomerId, order.Total, ct)); return new { order.OrderId, paymentId }; } } ``` ```csharp title="Program.cs" using Inngest; var builder = WebApplication.CreateBuilder(args); builder.Services .AddInngest(options => options.AppId = "shop") .AddFunction(); var app = builder.Build(); app.MapInngest("/api/inngest"); app.Run(); ``` ```csharp // IInngestClient is injected where it is needed. The record names the event. await inngest.SendAsync(order); ``` Every step tool, with the ids and values that the rules below ask for: ```csharp var order = context.Event.Data; var paymentId = await context.Step.RunAsync("charge", ct => payments.ChargeAsync(order.CustomerId, order.Total, ct)); await context.Step.SleepAsync("cool-off", TimeSpan.FromMinutes(30)); var dueAt = await context.Step.RunAsync("compute-due", () => DateTimeOffset.UtcNow.AddDays(1)); await context.Step.SleepUntilAsync("wait-until-due", dueAt); var shipped = await context.Step.WaitForEventAsync("wait-for-shipment", new WaitForEventOptions("shop/order.shipped", TimeSpan.FromDays(3)) { Match = "data.orderId" }); var approval = await context.Step.WaitForSignalAsync("wait-for-approval", new WaitForSignalOptions($"approval/{order.OrderId}", TimeSpan.FromDays(1))); var score = await context.Step.InvokeAsync("score-customer", new ScoreRequest(order.CustomerId)); await context.Step.SendEventAsync("announce", new InngestEvent("shop/order.paid", new { orderId = order.OrderId })); var rates = await context.Step.FetchAsync("get-rates", new HttpRequestMessage(HttpMethod.Get, "https://example.com/rates")); var verdict = await context.Step.InferAsync("check-fraud", new InferOptions( AIFormat.OpenAIChat, new Uri("https://api.openai.com/v1/chat/completions"), new { model = "gpt-4o-mini", messages = new[] { new { role = "user", content = "Is this order fraud?" } }, })); var note = await context.Step.AIWrapAsync("draft-note", $"Thank customer {order.CustomerId} for order {order.OrderId}.", prompt => chat.CompleteAsync(prompt, cancellationToken)); await context.Step.PublishAsync("publish-status", new RealtimeTopic($"order:{order.OrderId}", "status"), new { percent = 50 }); ``` ## Only in the .NET SDK These have no counterpart in inngest-js, so an assistant cannot know them from there: - **Fan-out over a list.** `MapAsync`, `ForEachAsync`, `MapOutcomesAsync` and `MapInChunksAsync` run a step for each item, with ids such as `charge[0]`, a limit on how many run at once, and child runs for lists beyond the 1,000 steps of a run. The list must come out the same on every call: take it from the event or from a step's result. See [Fan-out](https://damikun.github.io/inngest-dotnet/guides/fan-out.md). - **A scope per step.** `RunScopedAsync` runs a step in a dependency injection scope of its own, so a `DbContext` carries nothing from one step to the next. See [Scoped steps](https://damikun.github.io/inngest-dotnet/guides/scoped-steps.md). - **Webhooks.** `app.MapInngestWebhook(path, WebhookProvider.Stripe(secret))` checks a webhook's signature and sends it as an event. See [Webhooks](https://damikun.github.io/inngest-dotnet/guides/webhooks.md). - **Transactional outbox.** `db.AddInngestEvent(...)` saves an event in the same transaction as the app's data, and a dispatcher sends it after `SaveChangesAsync`. See [Transactional outbox](https://damikun.github.io/inngest-dotnet/guides/outbox.md). - **User context.** `AddUserContext(...)` carries the signed-in user into the runs their events start, as `ICurrentUser` or a `ClaimsPrincipal`. See [User context](https://damikun.github.io/inngest-dotnet/guides/user-context.md). - **Registration by a source generator.** `AddInngestFunctions()` registers every function class of a project that turns the generator on. See [Source generator and analyzers](https://damikun.github.io/inngest-dotnet/guides/source-generator.md). ```csharp // One step per invoice, charge[0] to charge[n], at most five at a time. await context.Step.MapAsync("charge", due, (invoice, ct) => billing.ChargeAsync(invoice.Id, ct), new FanOutOptions { MaxParallelism = 5 }); // A step with a dependency injection scope of its own, so the DbContext behind IInventory is new for it. await context.Step.RunScopedAsync("reserve", (inventory, ct) => inventory.ReserveAsync(context.Event.Data.OrderId, ct)); ``` ## Replay rules Inngest calls the function again after every step, and every call runs it from the top. A step that finished returns its saved result at once. The call that reaches a new step ends there: the SDK runs the step or reports it, and drops the rest of the function. Everything that is not in a step runs on every call. [How it works](https://damikun.github.io/inngest-dotnet/how-it-works.md) shows a run call by call, and [Steps](https://damikun.github.io/inngest-dotnet/guides/steps.md) covers ids and results. The rules follow from that. With checkpointing on, and in a [durable endpoint](https://damikun.github.io/inngest-dotnet/guides/durable-endpoints.md), a step that runs your code does not end the call. A step that has to wait, such as a sleep, a wait for an event or an invoke, still does, and later calls run the code from the top again. The rules are the same. ### 1. Put side effects in steps Code outside a step runs on every call, so an email, a database write or an API call there is repeated once per call. ```csharp var user = context.Event.Data; // Wrong: this runs on every call, so the user gets one email for each call. await email.SendAsync(user.Email, "Welcome!", cancellationToken); await context.Step.SleepAsync("wait-a-day", TimeSpan.FromDays(1)); ``` ```csharp var user = context.Event.Data; // Right: the step sends the email once and saves the result. await context.Step.RunAsync("send-welcome", ct => email.SendAsync(user.Email, "Welcome!", ct)); await context.Step.SleepAsync("wait-a-day", TimeSpan.FromDays(1)); ``` ### 2. Keep step ids the same on every call Inngest finds a saved result by the step's id. An id that changes between calls never finds one, so the step runs again on every call and the run does not get past it. Use a constant, or build the id from the event's data, the results of earlier steps and loop indexes. Never build it from the clock, `Guid.NewGuid()` or `Random`. [Steps](https://damikun.github.io/inngest-dotnet/guides/steps.md#step-ids) explains how repeated ids are numbered. ```csharp var order = context.Event.Data; // Wrong: a new id on every call, so no call finds a saved result and the charge runs every time. var paymentId = await context.Step.RunAsync($"charge-{Guid.NewGuid()}", ct => payments.ChargeAsync(order.CustomerId, order.Total, ct)); await context.Step.RunAsync($"mark-paid-{DateTimeOffset.UtcNow.Ticks}", ct => orders.MarkPaidAsync(order.OrderId, paymentId, ct)); ``` ```csharp var order = context.Event.Data; // Right: constant ids, or ids built from the event's data and loop indexes. var paymentId = await context.Step.RunAsync("charge", ct => payments.ChargeAsync(order.CustomerId, order.Total, ct)); await context.Step.RunAsync($"mark-paid-{order.OrderId}", ct => orders.MarkPaidAsync(order.OrderId, paymentId, ct)); for (var week = 1; week <= 3; week++) { await context.Step.SleepAsync($"wait-week-{week}", TimeSpan.FromDays(7)); } ``` ### 3. Get values that change from steps The time, random numbers and anything read from a database or an API can differ from one call to the next. A step saves the value it reads, so every call sees the same one. A list read outside a step can change between calls, and with it the steps that a loop over it creates. ```csharp // Wrong: the list is read again on every call. If it changes, so do the steps the loop creates. var invoices = await billing.GetOpenInvoicesAsync(customerId, cancellationToken); foreach (var invoice in invoices) { await context.Step.RunAsync($"charge-{invoice.Id}", ct => billing.ChargeAsync(invoice.Id, ct)); } ``` ```csharp // Right: a step reads each value and saves it. Later calls get the saved one. var invoices = await context.Step.RunAsync("load-invoices", ct => billing.GetOpenInvoicesAsync(customerId, ct)); var now = await context.Step.RunAsync("read-clock", () => DateTimeOffset.UtcNow); var reference = await context.Step.RunAsync("new-reference", () => Guid.NewGuid().ToString("N")); foreach (var invoice in invoices) { await context.Step.RunAsync($"charge-{invoice.Id}", ct => billing.ChargeAsync(invoice.Id, ct)); } ``` ### 4. Use the step's result, not a variable its handler sets A handler does not run again once its step has a saved result, so a variable it assigned is empty on every later call. ```csharp var order = context.Event.Data; // Wrong: on later calls the handler does not run, so paymentId stays null. string? paymentId = null; await context.Step.RunAsync("charge", async ct => { paymentId = await payments.ChargeAsync(order.CustomerId, order.Total, ct); }); await context.Step.RunAsync("mark-paid", ct => orders.MarkPaidAsync(order.OrderId, paymentId!, ct)); ``` ```csharp var order = context.Event.Data; // Right: the result is saved and handed back on every call. var paymentId = await context.Step.RunAsync("charge", ct => payments.ChargeAsync(order.CustomerId, order.Total, ct)); await context.Step.RunAsync("mark-paid", ct => orders.MarkPaidAsync(order.OrderId, paymentId, ct)); ``` ### 5. Do not rely on `finally` or `using` around a step The call that reaches a new step is dropped inside the block (with checkpointing or in a durable endpoint, the call that reaches a step that has to wait), so the `finally` block and the `Dispose` of a `using` do not run in that call. A lock released in `finally` stays held. Inside a step handler they run as usual. [Middleware](https://damikun.github.io/inngest-dotnet/guides/middleware.md#when-next-never-returns) has the same rule. To allow one run at a time for a key, across every instance of the app, use `[Concurrency(1, Key = "event.data.customerId")]`; see [Flow control](https://damikun.github.io/inngest-dotnet/guides/flow-control.md). ```csharp var order = context.Event.Data; // Wrong: the call that reaches the new step is dropped inside the try, so the finally never runs // and the lock stays held. await gate.WaitAsync(cancellationToken); try { await context.Step.RunAsync("charge", ct => payments.ChargeAsync(order.CustomerId, order.Total, ct)); } finally { gate.Release(); } ``` ```csharp var order = context.Event.Data; // Right: the lock lives inside the step handler, which runs to its end. await context.Step.RunAsync("charge", async ct => { await gate.WaitAsync(ct); try { return await payments.ChargeAsync(order.CustomerId, order.Total, ct); } finally { gate.Release(); } }); ``` ### 6. Never block on a step `.Result`, `.Wait()` and `GetAwaiter().GetResult()` on a step's task hang the call, whether or not the step has run before. The SDK hands results back on the function's own thread, which the blocked code is holding. Always `await`. To run steps in parallel, start them and `await Task.WhenAll(...)`; see [Parallel steps](https://damikun.github.io/inngest-dotnet/guides/parallel-steps.md). ```csharp var order = context.Event.Data; // Wrong: the call hangs. var paymentId = context.Step.RunAsync("charge", ct => payments.ChargeAsync(order.CustomerId, order.Total, ct)).Result; ``` ```csharp var order = context.Event.Data; // Right: await the step. var paymentId = await context.Step.RunAsync("charge", ct => payments.ChargeAsync(order.CustomerId, order.Total, ct)); ``` ### 7. Do not nest steps A step tool called inside another step's handler throws an `InvalidOperationException`. Call the steps from the function, one after the other. ```csharp var order = context.Event.Data; // Wrong: "charge" is called inside the handler of "pay-order". await context.Step.RunAsync("pay-order", async ct => { var paymentId = await context.Step.RunAsync("charge", c => payments.ChargeAsync(order.CustomerId, order.Total, c)); await orders.MarkPaidAsync(order.OrderId, paymentId, ct); }); ``` ```csharp var order = context.Event.Data; // Right: one step after the other, the second using the result of the first. var paymentId = await context.Step.RunAsync("charge", ct => payments.ChargeAsync(order.CustomerId, order.Total, ct)); await context.Step.RunAsync("mark-paid", ct => orders.MarkPaidAsync(order.OrderId, paymentId, ct)); ``` ### 8. Keep step outputs small A step's output is saved with the run, and Inngest limits it to 4 MiB. Store large data elsewhere and return a reference to it. Inngest's other limits: 32 MiB of state for a run, 1,000 steps in a run and 5,000 events in one send. The SDK does not check them; Inngest does. The [limits](https://www.inngest.com/docs/durable-execution/limits) page has the rest. ```csharp public interface IExports { // The file itself, which can be larger than a step may return. Task RenderAsync(string orderId, CancellationToken cancellationToken); // Saves the file to storage and returns where it is. Task StoreAsync(string orderId, CancellationToken cancellationToken); } ``` ```csharp var order = context.Event.Data; // Wrong: the whole file becomes the step's output. var file = await context.Step.RunAsync("render-export", ct => exports.RenderAsync(order.OrderId, ct)); ``` ```csharp var order = context.Event.Data; // Right: the handler stores the file and returns where it is. var location = await context.Step.RunAsync("store-export", ct => exports.StoreAsync(order.OrderId, ct)); ``` ### 9. In a durable endpoint, read the request in a step before the first wait A durable endpoint runs steps in an ASP.NET Core handler, and a call that resumes the run runs the handler from the top again, so rules 1 to 8 hold. A call that resumes it carries the original method, body and query, but not the headers or the user. Read what the handler needs from them in a step before its first step that waits, and the saved result comes back on every call. Code that the handler starts and that outlives the call must not touch `HttpContext`, which ASP.NET Core reuses once the request is over. ```csharp app.MapPost("/orders/{orderId}/ship", async (string orderId, HttpContext http, IStepTools step, IEmailSender email) => { // Wrong: the call that resumes the run after the sleep has no user, so recipient is empty there. var recipient = http.User.FindFirst("email")?.Value ?? ""; await step.SleepAsync("wait-for-packing", TimeSpan.FromSeconds(30)); await step.RunAsync("send-confirmation", ct => email.SendAsync(recipient, "Your order has shipped", ct)); return Results.Accepted(); }) .WithDurableEndpoint(); ``` ```csharp app.MapPost("/orders/{orderId}/ship", async (string orderId, HttpContext http, IStepTools step, IEmailSender email) => { // Right: a step reads the user once, before the first wait, and every call gets the saved result. var recipient = await step.RunAsync("read-recipient", () => http.User.FindFirst("email")?.Value ?? ""); await step.SleepAsync("wait-for-packing", TimeSpan.FromSeconds(30)); await step.RunAsync("send-confirmation", ct => email.SendAsync(recipient, "Your order has shipped", ct)); return Results.Accepted(); }) .WithDurableEndpoint(); ``` ## Add the rules to your repository Paste this block into `AGENTS.md`, `CLAUDE.md` or a Cursor rules file in the repository that uses the SDK. It holds the names and the rules above in a form an assistant can follow without reading the page. Cursor reads `AGENTS.md` and takes rules from `.cursor/rules` too. To keep one copy for Claude Code, put the block in `AGENTS.md` and import it from `CLAUDE.md` with `@AGENTS.md`. ```markdown title="AGENTS.md" ## Inngest .NET SDK (inngest-dotnet) This project uses inngest-dotnet, a community Inngest SDK for .NET. Its API is not inngest-js's. Do not write inngest-js code in C#, and do not invent member names: look them up. - Docs for tools: https://damikun.github.io/inngest-dotnet/llms.txt lists every page as Markdown, each page is also at its address with `.md` in place of the trailing slash, and https://damikun.github.io/inngest-dotnet/llms-full.txt holds all of them. Read https://damikun.github.io/inngest-dotnet/guides/ai-assistants.md before writing code with the SDK. Signatures are in https://damikun.github.io/inngest-dotnet/reference/api.md. - A function is a class that derives from `InngestFunction`, with `[InngestFunction("id")]` and `[EventTrigger("event/name")]` on it and `public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken)`. Register it with `AddFunction()` after `AddInngest(...)`, and serve it with `app.MapInngest("/api/inngest")`. - Steps are methods of `context.Step`: `RunAsync`, `SleepAsync`, `SleepUntilAsync`, `WaitForEventAsync`, `WaitForSignalAsync`, `InvokeAsync`, `SendEventAsync`, `FetchAsync`, `InferAsync`, `AIWrapAsync`, `PublishAsync`. Application code sends events with `IInngestClient.SendAsync`. Run steps in parallel with `Task.WhenAll`, or race them with `ParallelAsync(ParallelMode.Race, ...)`. - Only in .NET: `RunScopedAsync` runs a step in a dependency injection scope of its own (use it for a `DbContext`), and `MapAsync` or `ForEachAsync` runs a step for each item of a list, which must be the same on every call. Also `app.MapInngestWebhook(...)` for webhooks, `db.AddInngestEvent(...)` for a transactional outbox and `AddUserContext(...)` for the signed-in user. - Options are attributes on the function class: `[CronTrigger("0 6 * * 1-5")]`, `[Concurrency]`, `[Throttle]`, `[RateLimit]`, `[Debounce]`, `[BatchEvents]`, `[Priority]`, `[Singleton]`, `[CancelOn]`, `[Timeouts]`, and `Retries` and `Idempotency` on `[InngestFunction]`. A failure handler is `IFailureHandler.OnFailureAsync` on the same class. Errors that stop or delay retries: `NonRetriableException`, `RetryAfterException`. - Tests: `InngestTestHost` and `StepMocks` from `Inngest.Testing` run a function on the real execution engine with mocked steps. - A durable endpoint (beta) runs steps in an ASP.NET Core handler: `app.UseInngestDurableEndpoints()`, and `[DurableEndpoint("id")]` on a controller action or `.WithDurableEndpoint("id")` on a minimal API endpoint. The handler takes `IStepTools step` as a parameter and calls `step.RunAsync(...)`, with no `context`. The API is experimental: suppress the diagnostic `INNGEST001`. Inngest calls the function again after every step, and every call runs it from the top. A step that finished returns its saved result. The call that reaches a new step ends there (with checkpointing, or in a durable endpoint, the call that reaches a step that has to wait). So: 1. Side effects (emails, database writes, API calls) go inside steps only. Code outside a step runs on every call. 2. A step id is a constant, or is built from event data, results of earlier steps and loop indexes. Never from the clock, `Guid.NewGuid()` or `Random`. 3. Values that change between calls (the time, random numbers, anything read from a database or an API) come from a step: `var now = await context.Step.RunAsync("read-clock", () => DateTimeOffset.UtcNow);` 4. Use the value a step returns. A variable that a step handler assigns is empty on later calls, because the handler does not run again. 5. `finally` blocks, and the `Dispose` of a `using` around a step, do not run in the call that reaches a new step. Do not release a lock or a resource there. Take it inside the step handler. 6. Never block on a step's task with `.Result`, `.Wait()` or `GetAwaiter().GetResult()`. Always `await` it. 7. Never call a step inside another step's handler. Call the steps one after the other. 8. Keep a step's output under 4 MiB and return a reference to large data. A run holds at most 32 MiB of state and 1,000 steps, and one send takes at most 5,000 events. 9. In a durable endpoint, a call that resumes the run has the original method, body and query, but no headers and no user. Read them in a step before the first step that waits. Code the handler starts that outlives the call must not touch `HttpContext`. ``` --- # Functions, event triggers and cron in C# > Define Inngest functions as classes or delegates, with their options as attributes or in code, type their events, and start them from events, filtered events or cron schedules. Web page: https://damikun.github.io/inngest-dotnet/guides/functions-and-triggers/ A function is code that Inngest runs for you, plus options that tell Inngest when and how to run it. The code is an ordinary .NET method. The options are `FunctionOptions`, which you write in code when you register the function, or as attributes on its class. This page covers the code side and the triggers. Flow control, retries and the other options have pages of their own. ## Functions as classes Derive from `InngestFunction`, where `TEventData` is the type of the triggering event's data: ```csharp title="ChargeOrder.cs" using Inngest; public sealed record OrderPlaced(string OrderId, string CustomerId, decimal Total) : IInngestEventData { public static string EventName => "shop/order.placed"; } public sealed class ChargeOrder(IOrderStore orders, IPaymentGateway payments) : InngestFunction { public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) { var order = context.Event.Data; var paymentId = await context.Step.RunAsync("charge", ct => payments.ChargeAsync(order.CustomerId, order.Total, ct)); await context.Step.RunAsync("mark-paid", ct => orders.MarkPaidAsync(order.OrderId, paymentId, ct)); return new { order.OrderId, paymentId }; } } public interface IOrderStore { Task MarkPaidAsync(string orderId, string paymentId, CancellationToken cancellationToken); } public interface IPaymentGateway { Task ChargeAsync(string customerId, decimal amount, CancellationToken cancellationToken); } ``` Put the options on the class as attributes and register it with `AddFunction`, or pass the options to `AddFunction` in code: ### Attributes ```csharp [InngestFunction("charge-order", Name = "Charge an order")] [EventTrigger] ``` ```csharp title="Program.cs" builder.Services .AddInngest(options => options.AppId = "shop") .AddFunction(); ``` `[InngestFunction]` on the class gives the function its id, and optionally a name, retries and an idempotency key. The other attributes add triggers and flow control. `AddFunction` reads them once, when the app starts. With many classes, the [source generator](https://damikun.github.io/inngest-dotnet/guides/source-generator.md) can write `AddInngestFunctions()`, which registers every class that has `[InngestFunction]`, once the project turns it on. ### Code first ```csharp title="Program.cs" builder.Services .AddInngest(options => options.AppId = "shop") .AddFunction(new FunctionOptions { Id = "charge-order", Name = "Charge an order", Triggers = [Trigger.Event()], }); ``` `Id` and `Triggers` are required. The name, retries, flow control and the other options are optional. The SDK resolves a new instance from a dependency injection scope for every call Inngest makes, and disposes the scope when the call ends. Scoped services such as an Entity Framework `DbContext` work as they do in a controller. The scope is the call's, not a step's; [Scoped steps](https://damikun.github.io/inngest-dotnet/guides/scoped-steps.md) gives a step a scope of its own. You do not need to register the class yourself; `AddFunction` adds it as a transient service if it is not there. `ExecuteAsync` returns the function's output, which Inngest stores and shows with the run. Return `null` when there is nothing to return. The output is serialized to JSON, like everything a function hands to Inngest. To give the output a type, derive from `InngestFunction` instead; a function that [invokes](https://damikun.github.io/inngest-dotnet/guides/invoking-functions.md) this one then receives a `TResult`. ## Code first or attributes Both forms say the same things. Each attribute sets the `FunctionOptions` member of the same name, and [function options](https://damikun.github.io/inngest-dotnet/explainers/function-options.md) maps one to the other. As attributes, the options sit on the class, next to the code they describe, and take durations as time strings such as `"30s"`. In code, they sit with the registration, can use values the app reads at startup and take durations as `TimeSpan`s. These docs show attributes first; choosing **Code first** in one of the tabs switches the tabs on every page to that form. A function's options come from one place: options passed in code replace the class's attributes. To start from the attributes and change some of the options in code, for example a limit read from configuration, pass a callback: ```csharp var limit = builder.Configuration.GetValue("Billing:ChargeConcurrency", 10); inngest.AddFunction(options => options with { Concurrency = [new ConcurrencyLimit(limit)] }); ``` The callback is also where function middleware and checkpointing go for a class with attributes, since they have no attributes. ### Attributes of your own Every attribute but `[InngestFunction]` derives from `FunctionOptionsAttribute`, and an attribute of your own can too. It overrides `Configure` and returns the options with what it sets, which packages options that several functions share, such as the limits of an API they all call: ```csharp title="PaymentProviderAttribute.cs" using Inngest; // One charge per customer at a time, and more retries, for every function that calls the payment provider. public sealed class PaymentProviderAttribute : FunctionOptionsAttribute { protected override FunctionOptions Configure(FunctionOptions options) => options with { Retries = 10, Concurrency = [.. options.Concurrency ?? [], new ConcurrencyLimit(1) { Key = "event.data.customerId" }], }; } ``` ```csharp [InngestFunction("charge-order", Name = "Charge an order")] [EventTrigger] [PaymentProvider] ``` `AddFunction` starts from `[InngestFunction]` and applies the other attributes in ascending `Order`, which is 0 unless an attribute sets it, and those with the same `Order` in the order they are declared. Each one sees what the earlier ones set and replaces it where it sets the same member, so `[PaymentProvider]` replaces any `Retries` given on `[InngestFunction]`. ## Typed events `OrderPlaced` implements `IInngestEventData`, which puts the event's name on the record that carries its data. Code then names the type instead of repeating the string: `Trigger.Event()` and `[EventTrigger]` trigger on it, `SendAsync(new OrderPlaced(...))` [sends it](https://damikun.github.io/inngest-dotnet/guides/sending-events.md), and [waits](https://damikun.github.io/inngest-dotnet/guides/sleep-and-wait-for-events.md) and [cancellations](https://damikun.github.io/inngest-dotnet/guides/flow-control.md#cancel-on-events) can name it too. A mistyped name becomes a compile error, and nothing can send `UserDeleted` data under the name of `UserSignedUp`. `EventName` and the optional `EventVersion` are static members, which the SDK reads without reflection, so typed events work under Native AOT. Every method that takes a type also has a form that takes the name as a string. ## Functions as delegates Small functions can be lambdas, with their options in code. Without a type argument the event data is a `JsonElement`: ```csharp inngest.AddFunction(new FunctionOptions { Id = "hello", Triggers = [Trigger.Event("demo/hello")] }, async (context, cancellationToken) => { var name = context.Event.Data.GetProperty("name").GetString(); return await context.Step.RunAsync("greet", () => $"Hello, {name}!"); }); ``` With a type argument it is deserialized as it is for a class: ```csharp inngest.AddFunction(new FunctionOptions { Id = "log-order", Triggers = [Trigger.Event()] }, async (context, cancellationToken) => { context.Logger.LogInformation("Order {OrderId} for {Total}", context.Event.Data.OrderId, context.Event.Data.Total); return null; }); ``` A delegate is created once, so it cannot take constructor dependencies. Resolve services from `context.Services`, which is the scope of the current call, or use a class. ## The function context `FunctionContext` carries everything about the current call: | Property | What it holds | | - | - | | `Event` | The triggering event, with `Data` deserialized into `TEventData`, plus `Name`, `Id`, `Timestamp`, `User` and `Version`. | | `Events` | Every event of a batch when the function uses `BatchEvents`; otherwise a list with `Event` only. | | `Step` | The step tools: `RunAsync`, `SleepAsync`, `SleepUntilAsync`, `WaitForEventAsync`, `InvokeAsync` and `SendEventAsync`. | | `RunId` | The id of the run. It stays the same across every call and retry of one run. | | `Attempt` | Zero on the first attempt of the current step or function, one more on every retry. | | `MaxAttempts` | The number of attempts Inngest allows, when it says so. | | `Logger` | An `ILogger` that stays quiet while memoized steps replay, so each message is written once per run. | | `Services` | The dependency injection scope of this call. | | `FunctionId` | The function's id as registered, without the app id. | | `CancellationToken` | Canceled when Inngest abandons the call or the app shuts down. Functions also receive it as a parameter. | ## Event data The SDK deserializes `data` with `InngestOptions.SerializerOptions`, which default to `JsonSerializerDefaults.Web`: property names are camel case and matched without regard to case. A missing or `null` `data` is read as an empty object. Use records with the properties you need; properties the event has and the record does not are ignored. Use `JsonElement` when the shape varies or when you only need a field or two. To change how JSON is read, for example to add converters, change `SerializerOptions` when you call `AddInngest`. [Native AOT and trimming](https://damikun.github.io/inngest-dotnet/guides/native-aot.md) shows how to register source-generated types. ## Event triggers `[EventTrigger]` runs the function for every event of type `T`, and `[EventTrigger("name")]` for every event with that name; in code they are `Trigger.Event()` and `Trigger.Event("name")`. The optional `If` is an [expression](https://www.inngest.com/docs/guides/writing-expressions) that Inngest evaluates against the event before it starts a run: ### Attributes ```csharp [EventTrigger(If = "event.data.total >= 500")] [EventTrigger("shop/order.flagged")] ``` ### Code first ```csharp Triggers = [ Trigger.Event(@if: "event.data.total >= 500"), Trigger.Event("shop/order.flagged"), ], ``` A function can have up to ten triggers and runs for any of them. Inngest refuses a sync with the same trigger twice, or the same cron schedule twice even with different jitter, so the SDK refuses both at startup. Event names are free text, but Inngest suggests a `domain/noun.verb` style in the past tense, such as `billing/invoice.paid`. Names that start with `inngest/` belong to Inngest; the SDK refuses to send them. The [event and trigger concepts](https://www.inngest.com/docs/durable-execution/guides-and-advanced/events-and-triggers/event-and-trigger-concepts) page of the Inngest docs has more, including wildcard triggers. ## Cron triggers `[CronTrigger]`, or `Trigger.Cron` in code, runs the function on a schedule. Schedules have five fields and run in UTC unless you give a time zone: ### Attributes ```csharp [CronTrigger("0 6 * * 1-5", Timezone = "Europe/Prague")] [CronTrigger("TZ=America/New_York 30 17 * * 5", Jitter = "2m")] ``` ### Code first ```csharp Triggers = [ Trigger.Cron("0 6 * * 1-5", timezone: "Europe/Prague"), Trigger.Cron("TZ=America/New_York 30 17 * * 5", TimeSpan.FromMinutes(2)), ], ``` The SDK sends the time zone as a `TZ=` prefix, which is how Inngest reads it, so both forms above work the same way. The jitter of the second schedule starts each scheduled run up to two minutes later, so that functions on the same schedule do not all start at once; it can be from 1 second to 5 minutes. A scheduled run has an event too, named `inngest/scheduled.timer`, so read its data as `JsonElement`. See [schedules and delayed starts](https://www.inngest.com/docs/durable-execution/guides-and-advanced/events-and-triggers/schedules-and-delayed-starts) in the Inngest docs for how schedules behave around time zone changes. ## Functions without triggers A function registered with an empty trigger list, or a class with `[InngestFunction]` and no trigger attribute, never starts on its own. Other functions run it with [`InvokeAsync`](https://damikun.github.io/inngest-dotnet/guides/invoking-functions.md), by its class: ```csharp title="ScoreAccount.cs" using System.Text.Json; using Inngest; public sealed class ScoreAccount : InngestFunction { public override Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) => Task.FromResult(context.Event.Data.GetProperty("accountId").GetString()!.Length * 10); } ``` ### Attributes ```csharp [InngestFunction("score-account")] ``` The attribute goes on `ScoreAccount`, and `inngest.AddFunction()` registers it. ### Code first ```csharp inngest.AddFunction(new FunctionOptions { Id = "score-account", Triggers = [] }); ``` ## Ids and names The id is required and must be unique within the app. Inngest knows the function as `-`, so changing the id, or the app id, makes a new function and leaves the old one without code. The name is only for display and defaults to the id. The SDK checks every function's options when the app starts, whether they come from code or attributes. If something is invalid, such as a duplicate id or an option out of range, it throws one `InvalidOperationException` that lists every problem it found, each naming its function. It does not wait for Inngest to reject the sync. Attribute durations are time strings, such as `"30s"`, `"5m"` or `"1h30m"`, because attribute arguments must be constants; one that is not a time string fails when `AddFunction` reads it. To change a function while runs are in flight, keep its id and the ids of the steps those runs have already reached. Inngest's [versioning guide](https://www.inngest.com/docs/durable-execution/guides-and-advanced/versioning) explains what is safe to change. ## Related examples [Nightly billing](https://damikun.github.io/inngest-dotnet/examples/nightly-billing.md) runs on a cron trigger, and [fan-out with invoke](https://damikun.github.io/inngest-dotnet/examples/fan-out-with-invoke.md) has a function without triggers. --- # Durable steps: RunAsync and step ids > How RunAsync and the other step tools make code durable, how step ids work, and what to keep in and out of steps. Web page: https://damikun.github.io/inngest-dotnet/guides/steps/ A step is a piece of a function that Inngest records. `context.Step.RunAsync` runs its handler once and saves the result. Inngest runs a function by calling it again and again, and on every later call the step returns the saved result instead of running again. If the handler throws, only that step is retried. ```csharp var order = context.Event.Data; var paymentId = await context.Step.RunAsync("charge", ct => payments.ChargeAsync(order.CustomerId, order.Total, ct)); await context.Step.RunAsync("mark-paid", ct => orders.MarkPaidAsync(order.OrderId, paymentId, ct)); ``` If `mark-paid` fails, Inngest retries it, and the function's code runs again from the top: `charge` returns the saved payment id without charging the card a second time. [How it works](https://damikun.github.io/inngest-dotnet/how-it-works.md) shows this call by call, and the Inngest docs cover [steps in general](https://www.inngest.com/docs/durable-execution/primitives/step-run). Figure: Inngest calls the function once for each step. Every call runs the code from the top; steps that already ran return their saved result at once, so `charge` runs a single time even though three calls pass it. ## The step tools | Method | What it does | More | | - | - | - | | `RunAsync(id, handler)` | Runs code once, saves its result, retries it on failure. | This page | | `SleepAsync(id, duration)` | Pauses the run for a duration. | [Sleep and wait for events](https://damikun.github.io/inngest-dotnet/guides/sleep-and-wait-for-events.md) | | `SleepUntilAsync(id, time)` | Pauses the run until a point in time. | [Sleep and wait for events](https://damikun.github.io/inngest-dotnet/guides/sleep-and-wait-for-events.md) | | `WaitForEventAsync(id, options)` | Waits for a matching event, or returns `null` at the timeout. | [Sleep and wait for events](https://damikun.github.io/inngest-dotnet/guides/sleep-and-wait-for-events.md) | | `InvokeAsync(id, options)` | Runs another function and returns its result. | [Invoking functions](https://damikun.github.io/inngest-dotnet/guides/invoking-functions.md) | | `SendEventAsync(id, events)` | Sends events exactly once per run. | [Sending events](https://damikun.github.io/inngest-dotnet/guides/sending-events.md) | | `AIWrapAsync(id, input, handler)` | Runs your own model call as a step that the Inngest UI shows as one. | [Calls to AI models](https://damikun.github.io/inngest-dotnet/guides/steps.md#calls-to-ai-models), [AI chat clients](https://damikun.github.io/inngest-dotnet/guides/ai.md) | | `RunScopedAsync(id, handler)` | Runs code once like `RunAsync`, in a dependency injection scope of its own, so a `DbContext` is new for every step. | [Scoped steps](https://damikun.github.io/inngest-dotnet/guides/scoped-steps.md) | | `PublishAsync(id, topic, data)` | Publishes a realtime message once. | [Realtime](https://damikun.github.io/inngest-dotnet/guides/realtime.md) | All of them are on `IStepTools`, which is an interface so that functions can be [unit tested](https://damikun.github.io/inngest-dotnet/guides/testing.md) with a fake. ## Step ids Inngest stores each result under its step id, so ids have two rules: - **Unique within the run.** Two different steps with the same id would share one result. - **Stable.** The same step must get the same id on every call, and in every version of the code that runs while a run is in flight. Do not build ids from the time, random values or anything else that changes between calls. Reusing an id in a loop is allowed. The SDK numbers repeated ids in the order they are called, so the second `charge-invoice` below is stored as `charge-invoice:1`, the third as `charge-invoice:2` and so on: ```csharp var invoices = await context.Step.RunAsync("load-invoices", ct => billing.GetOpenInvoicesAsync(customerId, ct)); foreach (var invoice in invoices) { await context.Step.RunAsync("charge-invoice", ct => billing.ChargeAsync(invoice.Id, ct)); } ``` This works because the list comes from a step, so it is the same on every call. Ids built from stable data, such as `$"charge-{invoice.Id}"`, work too and are easier to read in the run history. Inngest stores results under the SHA-1 of the id, and the run history shows the id you wrote. ## Handlers `RunAsync` takes a handler in whichever shape fits: ```csharp // Asynchronous, with the call's cancellation token. var invoices = await context.Step.RunAsync("load", ct => billing.GetOpenInvoicesAsync("cus_42", ct)); // Asynchronous without a token, or synchronous. var page = await context.Step.RunAsync("fetch", () => http.GetStringAsync("https://example.com/feed")); var reference = await context.Step.RunAsync("create-reference", () => Guid.NewGuid().ToString("N")); // No result. await context.Step.RunAsync("notify", ct => billing.NotifyAsync("cus_42", ct)); ``` The cancellation token passed to the handler is canceled when Inngest abandons the call or the app shuts down. A handler that returns a `ValueTask` is rejected with an `ArgumentException`, because the SDK would otherwise save the `ValueTask` itself instead of waiting for it. Make the lambda `async` or call `AsTask()`. ## Results are JSON A step's result is serialized to JSON and stored by Inngest; every later call deserializes it. The SDK does this on the call that ran the step too, so the function always sees the result the same way. Return types that round-trip through `System.Text.Json`: records, DTOs, primitives, lists. Do not return services, streams, or entities with navigation properties. A run keeps the results its steps returned, and every later call reads them with the code deployed then. When a change to a step's result type leaves stored JSON that no longer fits it, such as a property that became a number, the runs in flight fail when they reach that step. The error names the step and the type, and the run is retried as for any other error, so a fix or a rollback deployed before the retries run out lets those runs go on. To change what a step returns, keep the new type able to read the old JSON, or give the step a new id, so that runs in flight run it again instead of reading the old result. Results count toward Inngest's [limits](https://www.inngest.com/docs/durable-execution/limits): a few MiB per step and in total per run. Store large data elsewhere and return a reference to it. ## Keep side effects in steps Code outside steps runs on every call. That is fine for reading the event, computing values from step results and branching on them. Anything that changes the world, or that gives a different answer each time, belongs in a step: sending email, writing to a database, calling an API, reading the clock, generating ids. The same goes for variables. A handler does not run again once its step is memoized, so a value assigned inside it is lost on later calls. Return the value instead: ```csharp // Wrong: total is only assigned on the call that runs the step. On later calls it stays 0. var total = 0m; await context.Step.RunAsync("sum-invoices", async ct => { total = await billing.SumOpenAsync("cus_42", ct); }); ``` ```csharp // Right: the result is saved and handed back on every call. var total = await context.Step.RunAsync("sum-invoices", ct => billing.SumOpenAsync("cus_42", ct)); ``` ## Logging `context.Logger` stays quiet while the function replays memoized steps and starts writing once it reaches new code, so a message between two steps is written once per run instead of once per call. Loggers you inject yourself do not know about replays. ```csharp var paymentId = await context.Step.RunAsync("charge", ct => payments.ChargeAsync(context.Event.Data.CustomerId, context.Event.Data.Total, ct)); context.Logger.LogInformation("Charged order {OrderId} with payment {PaymentId}", context.Event.Data.OrderId, paymentId); ``` ## Steps cannot be nested Calling a step tool inside another step's handler throws an `InvalidOperationException`. Call steps from the function itself, one after the other or [in parallel](https://damikun.github.io/inngest-dotnet/guides/parallel-steps.md). ## Calls to AI models A model call is a step like any other, and two step tools make it show as one in the Inngest UI. `InferAsync` has Inngest make the request to the provider, so no worker waits for the answer. `AIWrapAsync` runs your own call, such as one through `IChatClient` of `Microsoft.Extensions.AI` behind a client of your own, as a run step that the UI shows with its input: ```csharp var note = await context.Step.AIWrapAsync( "draft-note", $"Write a short thank-you note for order {context.Event.Data.OrderId}.", prompt => chat.CompleteAsync(prompt, cancellationToken)); ``` The input is saved with the step, and a rerun from the Inngest UI with an edited input hands the handler the edited one. Keep the input serializable, as a step's result is. For a whole `IChatClient` pipeline, with its tool loop, [AI chat clients](https://damikun.github.io/inngest-dotnet/guides/ai.md) shows how the `Inngest.Extensions.AI` package makes every model call and tool call a step without wrapping each one by hand. ## Retries and failures Each step has its own retry count: with `Retries = 3`, every step gets up to four attempts. When a step fails all of them, the SDK throws `StepFailedException` where the function awaits the step. Catch it to recover; left uncaught, it fails the run. [Errors and retries](https://damikun.github.io/inngest-dotnet/guides/errors-and-retries.md) covers this, along with `NonRetriableException` and `RetryAfterException`. A handler can also run again after its work succeeded, when the call that ran it is lost before Inngest records the result. Write steps that are safe to repeat, such as a database write keyed by the run and the step; [Scoped steps](https://damikun.github.io/inngest-dotnet/guides/scoped-steps.md#idempotency-keys) shows one. ## Related examples [Order fulfillment](https://damikun.github.io/inngest-dotnet/examples/order-fulfillment.md) and [nightly billing](https://damikun.github.io/inngest-dotnet/examples/nightly-billing.md) are built from steps throughout. --- # Scoped steps and Entity Framework Core > Run a step in a dependency injection scope of its own with RunScopedAsync, so a DbContext never carries state from one step or call to the next, and write database steps that are safe to retry and replay. Web page: https://damikun.github.io/inngest-dotnet/guides/scoped-steps/ A function's services come from a dependency injection scope that lives for one call from Inngest, and scoped services such as an Entity Framework `DbContext` work as they do in a controller. A call is not a step, though. Inngest calls the function again for every step, the function's own code runs again each time, and with [checkpointing](https://damikun.github.io/inngest-dotnet/reference/api.md#options) one call runs several steps. A `DbContext` injected into the function is a single instance for all of that. Entity Framework describes a `DbContext` as a unit of work, and a step is the natural boundary for one. `context.Step.RunScopedAsync` runs a step in a scope of its own instead. The handler resolves what it needs from that scope, and the scope is disposed when the handler ends, so nothing a step tracks or leaves open reaches the next step, the next attempt or the next replay. It is part of the `Inngest` package and knows nothing about Entity Framework; this page uses `DbContext` because it is the service that needs it most. The examples use this model: ```csharp using Microsoft.EntityFrameworkCore; public sealed class ShopDbContext(DbContextOptions options) : DbContext(options) { public DbSet Orders => Set(); public DbSet Payments => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.Entity().HasIndex(payment => new { payment.RunId, payment.StepId }).IsUnique(); } public sealed class Order { public required string Id { get; init; } public decimal Total { get; init; } public string Status { get; set; } = "placed"; public string? Note { get; set; } public List Lines { get; } = []; } public sealed class OrderLine { public int Id { get; set; } public required string Sku { get; init; } public int Quantity { get; init; } public Order? Order { get; set; } } public sealed class Payment { public int Id { get; set; } public required string RunId { get; init; } public required string StepId { get; init; } public required string OrderId { get; init; } public decimal Amount { get; init; } } public sealed record PaymentRecorded(int PaymentId); public sealed record LineSummary(string Sku, int Quantity); public sealed record OrderSummary(string Id, string Status, List Lines); ``` ## What the call's scope shares A `DbContext` tracks every entity it loads or changes, and `SaveChangesAsync` saves all of them. The context injected into the function belongs to the call, so a step that saves also saves whatever the rest of the call left in it: ```csharp using Microsoft.EntityFrameworkCore; // A read and an unsaved change made outside a step, so they happen again on every call. var order = await db.Orders.SingleAsync(o => o.Id == context.Event.Data.OrderId, cancellationToken); order.Note = $"Seen at {DateTimeOffset.UtcNow:O}"; await context.Step.RunAsync("record-payment", async ct => { db.Payments.Add(new Payment { RunId = context.RunId, StepId = "record-payment", OrderId = order.Id, Amount = order.Total }); // Saves the payment, and the note too, which nothing asked this step to save. await db.SaveChangesAsync(ct); }); ``` It works the other way round too: when earlier code has already loaded a row, a tracking query returns that instance with the values it holds, not the values it just read, and `FindAsync` does not query at all. A scoped step starts from an empty context every time: ```csharp var order = context.Event.Data; var payment = await context.Step.RunScopedAsync("record-payment", async (ShopDbContext db, CancellationToken ct) => { var row = new Payment { RunId = context.RunId, StepId = "record-payment", OrderId = order.OrderId, Amount = order.Total }; db.Payments.Add(row); await db.SaveChangesAsync(ct); return new PaymentRecorded(row.Id); }); ``` ## RunScopedAsync The handler can ask for the scope's `IServiceProvider`, or for one service, which the SDK resolves with `GetRequiredService`. A handler without a result returns `Task`, and every overload also takes a `StepOptions` in place of the id, like the other step tools. Prefer the form that names the service, with the type of the first parameter written so that the compiler can infer it: ```csharp using Microsoft.EntityFrameworkCore; // One service: the type of the first parameter is written, and the SDK resolves it from the scope. var open = await context.Step.RunScopedAsync("count-open", (ShopDbContext db, CancellationToken ct) => db.Orders.CountAsync(o => o.Status == "placed", ct)); // Several services: ask for the scope's provider and resolve them from it. var total = await context.Step.RunScopedAsync("sum-orders", async (services, ct) => { var db = services.GetRequiredService(); return await db.Orders.SumAsync(o => o.Total, ct); }); // No result. await context.Step.RunScopedAsync("archive", async (ShopDbContext db, CancellationToken ct) => { await db.Orders.Where(o => o.Status == "shipped").ExecuteDeleteAsync(ct); }); ``` Type arguments work too, with one catch. Two, as in `RunScopedAsync`, keep the result. One, as in `RunScopedAsync`, picks the form without a result, so a value the lambda returns is dropped: ```csharp using Microsoft.EntityFrameworkCore; var all = await context.Step.RunScopedAsync("count-all", (db, ct) => db.Orders.CountAsync(ct)); // The number of rows deleted is dropped. await context.Step.RunScopedAsync("purge", (db, ct) => db.Orders.Where(o => o.Status == "cancelled").ExecuteDeleteAsync(ct)); ``` Both forms resolve services with `GetRequiredService()` and use no reflection of their own, so they work in trimmed and Native AOT apps as far as the services do. To Inngest a scoped step is a run step, reported exactly as `RunAsync` reports one, with the same id rules, retries and results. What differs is the scope: - **It lives as long as the handler.** The SDK creates it when the handler starts and disposes it when the handler ends, whether the handler returned or threw, asynchronously for services that are `IAsyncDisposable`. That is before the result is saved. Pass the handler's `CancellationToken` to every call it makes: a handler that ignores it keeps its scope open until it returns, which can be after the call has ended. - **Every execution gets a new one.** Each attempt of a step, each step of a checkpointed call and each step that [runs in parallel](https://damikun.github.io/inngest-dotnet/guides/parallel-steps.md) has a scope of its own. Steps in parallel already run in calls of their own, so what the scope keeps apart is a step from the function's own code, which uses the call's scope, and from the other steps that one call runs one after another. A step whose result Inngest already has, a step that is only planned and a step a test mocks create none. - **It is a scope of its own, not the call's.** With Microsoft's container, `IServiceScopeFactory` makes a scope next to the call's, not inside it; a container that nests scopes may differ. Either way, scoped services in it are new instances and singletons are shared, but nothing that code stored in the call's scope is there, such as a tenant that middleware set on a scoped service. Set it from the step: `services.GetRequiredService().Set(tenantId)`. - **Middleware keeps seeing the call's scope.** `FunctionContext.Services` is the same for every hook. `WrapStepHandlerAsync` wraps the scope: it is created inside `next()` and gone when `next()` returns, and the step's `StepInfo.Type` is `Run`. See [Middleware](https://damikun.github.io/inngest-dotnet/guides/middleware.md#registration-and-lifetime). - **Disposal is for releasing, not for work.** An exception thrown while the scope is disposed is only logged, at the error level. The step's outcome stands and nothing retries it, and a handler that threw keeps its own exception. So never commit, flush or save in the `Dispose` or `DisposeAsync` of a scoped service: a failure there is lost while the step is recorded as a success. Do that in the handler, with a call that can fail the step. - **Steps cannot be nested.** Calling a step tool inside the handler throws, as it does in `RunAsync`. ## Results are plain data The result is serialized to JSON after the scope has been disposed, and every replay reads it back ([Results are JSON](https://damikun.github.io/inngest-dotnet/guides/steps.md#results-are-json)); it also counts toward Inngest's [limits](https://www.inngest.com/docs/durable-execution/limits) on the size of a step's result. Return records, lists and ids, never tracked entities. An entity with navigation properties usually refers back to itself, which `System.Text.Json` refuses, and a query that has not run yet, such as an `IQueryable` or a lazy `Select`, would need the context that is gone. Project in the query and return what the function needs next: ```csharp using Microsoft.EntityFrameworkCore; var summary = await context.Step.RunScopedAsync("load-order", (ShopDbContext db, CancellationToken ct) => db.Orders .Where(o => o.Id == orderId) .Select(o => new OrderSummary(o.Id, o.Status, o.Lines.Select(line => new LineSummary(line.Sku, line.Quantity)).ToList())) .SingleAsync(ct)); ``` A result that cannot be serialized fails the step after its handler has finished. The work is already committed by then, and the retry does it again, which is a reason to make steps safe to repeat, as described below. Do not hand an entity from one step to another either. Re-attaching a copy that a step returned, with `Update`, writes every column it holds, including values that changed in the meantime. Pass the id and load the row again where it is needed. ## Entity Framework Core ### One SaveChangesAsync per step A step is the unit that Inngest retries, and it records the step's result only when the handler returns. Give each step one `SaveChangesAsync`: for a relational provider it runs in a transaction of its own, so the step writes everything or nothing, and a retry starts from a clean state. Work that is separate, such as taking the payment and booking the shipment, belongs in separate steps, so that a failure retries only what failed. Return what the next step needs, such as the generated key, after the save. Register the context as usual. With `AddDbContextPool` a step takes a context from the pool when it resolves one and the scope gives it back, reset, when the step ends: ```csharp using Microsoft.EntityFrameworkCore; builder.Services.AddDbContextPool(options => { // Your provider, for example options.UseNpgsql(connectionString). }); ``` If a step needs only a `DbContext`, `IDbContextFactory` does the same without the SDK. `AddDbContextPool` does not register the factory: register it with `AddDbContextFactory`, or `AddPooledDbContextFactory` for a pool, inject it into the function and create the context inside a plain `RunAsync`. A scoped step is for steps that use several scoped services that must share one context, such as repositories or a unit of work class registered as scoped. Such a class commits when the handler tells it to, with a call that can fail the step, never when it is disposed. ```csharp using Microsoft.EntityFrameworkCore; builder.Services.AddDbContextFactory(options => { // Your provider, for example options.UseNpgsql(connectionString). }); ``` ```csharp using Microsoft.EntityFrameworkCore; var count = await context.Step.RunAsync("count-open", async ct => { await using var db = await dbFactory.CreateDbContextAsync(ct); return await db.Orders.CountAsync(o => o.Status == "placed", ct); }); ``` ### Transactions and execution strategies A step that needs several saves in one transaction opens the transaction itself. If the provider is set up with a retrying execution strategy, such as `EnableRetryOnFailure()`, Entity Framework throws on the first command you run in a transaction you started yourself, unless the whole transaction runs inside `strategy.ExecuteAsync`. The strategy runs the delegate again after a transient failure, so the delegate must start from a clean state: clear the change tracker and build the entities inside it. Pass the step's token to `ExecuteAsync` as well, so that the strategy stops retrying, and waiting between retries, when the call is canceled. The delegate also looks for the payment that an earlier execution committed before it does anything else, for the reason given under [idempotency keys](https://damikun.github.io/inngest-dotnet/guides/scoped-steps.md#idempotency-keys): ```csharp using Microsoft.EntityFrameworkCore; var order = context.Event.Data; var captured = await context.Step.RunScopedAsync("capture-payment", async (ShopDbContext db, CancellationToken ct) => { var strategy = db.Database.CreateExecutionStrategy(); return await strategy.ExecuteAsync(async token => { db.ChangeTracker.Clear(); await using var transaction = await db.Database.BeginTransactionAsync(token); // An earlier execution of this step may have committed and then been lost. var existing = await db.Payments.SingleOrDefaultAsync(p => p.RunId == context.RunId && p.StepId == "capture-payment", token); if (existing is not null) { return new PaymentRecorded(existing.Id); } var row = await db.Orders.SingleAsync(o => o.Id == order.OrderId, token); row.Status = "paid"; await db.SaveChangesAsync(token); var payment = new Payment { RunId = context.RunId, StepId = "capture-payment", OrderId = row.Id, Amount = row.Total }; db.Payments.Add(payment); await db.SaveChangesAsync(token); await transaction.CommitAsync(token); return new PaymentRecorded(payment.Id); }, ct); }); ``` Without a retrying strategy the delegate runs once. If the connection drops while the transaction commits, Entity Framework cannot tell whether it committed and may run the delegate again; the lookup at the top makes that harmless. If two executions of the step overlap, the unique index fails the save of the second, and its retry finds the payment. See Microsoft's pages on [connection resiliency](https://learn.microsoft.com/ef/core/miscellaneous/connection-resiliency) and [the lifetime of a DbContext](https://learn.microsoft.com/ef/core/dbcontext-configuration/). ### Idempotency keys A step's handler can run again after its work has been committed. Inngest records a step's result when the call that ran the step answers with it, or when [checkpointing](https://damikun.github.io/inngest-dotnet/reference/api.md#options) saves it. If the call is lost before then, because the process stops, a proxy times out or the connection drops, Inngest retries the step, and the handler finds its rows already written. With checkpointing and a `BufferedSteps` above 1, every step that was not saved yet runs again. The same happens when the result cannot be serialized. Inngest's own page on [idempotency](https://www.inngest.com/docs/durable-execution/guides-and-advanced/idempotency) makes the point about external services. Deduplicating the event or the function, as [flow control](https://damikun.github.io/inngest-dotnet/guides/flow-control.md#idempotency) does, decides whether a run starts, not whether a step runs twice. So key the write by what stays the same across executions of the step: the run id and the step id. A new `Guid` or `context.Attempt` would differ each time, which defeats the key. Put a unique index on the key, as the model above does for `Payment`, so the database refuses a second row even when two executions overlap, and make the handler return the row that is there: ```csharp using Microsoft.EntityFrameworkCore; var order = context.Event.Data; var recorded = await context.Step.RunScopedAsync("record-payment", async (ShopDbContext db, CancellationToken ct) => { var existing = await db.Payments.SingleOrDefaultAsync(p => p.RunId == context.RunId && p.StepId == "record-payment", ct); if (existing is not null) { return new PaymentRecorded(existing.Id); } var payment = new Payment { RunId = context.RunId, StepId = "record-payment", OrderId = order.OrderId, Amount = order.Total }; db.Payments.Add(payment); try { await db.SaveChangesAsync(ct); } catch (DbUpdateException) { // An execution that overlapped this one committed first, and the unique index refused this row. db.ChangeTracker.Clear(); var winner = await db.Payments.SingleOrDefaultAsync(p => p.RunId == context.RunId && p.StepId == "record-payment", ct); if (winner is null) { throw; } return new PaymentRecorded(winner.Id); } return new PaymentRecorded(payment.Id); }); ``` A step id used in a loop is numbered (`record-payment:1`), so build the id from the item, as in `$"record-payment-{invoice.Id}"`, and key by that. Other steps need other shapes: - A step that creates a row keys it by the run and the step, as above, or by a natural key such as the order id, behind a unique index. - A step that sets a value to a fixed one is safe to repeat as it is. So is an upsert, but EF Core has no upsert API: it takes raw SQL, `ExecuteSqlInterpolatedAsync` with the provider's `ON CONFLICT` or `MERGE`, or the look-up-then-insert shown above. - A step that changes a value relative to its old one, such as a stock count, is not safe to repeat. Record the change in a row keyed by the run and the step, in the same save: look for the row first and skip the change when it is there, and let the unique index catch executions that overlap. Keep the keys as long as the run can still retry the step. A function triggered by `inngest/function.finished` can delete them when the run ends. ### Reads between steps Code outside steps runs on every call, and what a read finds can change between two calls. A branch taken on such a read can differ from call to call, so the function reaches different steps than the run recorded. Put every read that decides what the function does into a step, so that its result is saved: ```csharp using Microsoft.EntityFrameworkCore; var status = await context.Step.RunScopedAsync("load-status", (ShopDbContext db, CancellationToken ct) => db.Orders.Where(o => o.Id == orderId).Select(o => o.Status).SingleAsync(ct)); if (status == "paid") { await context.Step.RunAsync("ship", () => Task.CompletedTask); } ``` ## Testing The [test host](https://damikun.github.io/inngest-dotnet/guides/testing.md#the-test-host) runs scoped steps like any other, with a new scope for each, and a mock replaces one where its handler would run, so `new StepMocks().Run("record-payment", new PaymentRecorded(1))` answers the step above without touching a database. To test the database code, run it against a real database: SQLite in memory or, better, the kind you deploy to. The EF Core in-memory provider does not support transactions, and a test of an idempotency key needs a database that enforces the unique index. A [fake `IStepTools`](https://damikun.github.io/inngest-dotnet/guides/testing.md#a-fake-for-the-step-tools) has to implement `RunScopedAsync` itself: the default throws `NotSupportedException`, so that fakes written before it keep compiling. Creating a scope from a provider of the test and running the handler in it is all it takes: ```csharp using Inngest; using Microsoft.Extensions.DependencyInjection; public sealed class ScopingStepTools(IServiceProvider services) : IStepTools { public async Task RunScopedAsync(string id, Func> handler) { await using var scope = services.CreateAsyncScope(); return await handler(scope.ServiceProvider, CancellationToken.None); } public async Task RunAsync(string id, Func> handler) => await handler(CancellationToken.None); public Task SleepAsync(string id, TimeSpan duration) => Task.CompletedTask; public Task SleepUntilAsync(string id, DateTimeOffset until) => Task.CompletedTask; public Task?> WaitForEventAsync(string id, WaitForEventOptions options) => Task.FromResult?>(null); public Task InvokeAsync(string id, InvokeOptions options) => throw new NotSupportedException(); public Task SendEventAsync(string id, IReadOnlyList events) => throw new NotSupportedException(); } ``` The overloads that take a `StepOptions`, or a service type, or no result, all end up in that one method. --- # Parallel steps with Task.WhenAll > Run steps at the same time with Task.WhenAll and Task.WhenAny, how Inngest executes them, and what to watch out for. Web page: https://damikun.github.io/inngest-dotnet/guides/parallel-steps/ Steps run in parallel when the function starts them before awaiting them. There is no special API: start the tasks, then await them with `Task.WhenAll` or `Task.WhenAny`. ```csharp var user = context.Event.Data; var profile = context.Step.RunAsync("load-profile", ct => profiles.LoadAsync(user.UserId, ct)); var orders = context.Step.RunAsync("load-orders", ct => orderHistory.LoadAsync(user.UserId, ct)); await Task.WhenAll(profile, orders); context.Logger.LogInformation("{Name} has {Count} orders", (await profile).Name, (await orders).Count); ``` ## How Inngest runs them When a call finds more than one new step, the SDK runs none of them. It answers with a `StepPlanned` op for each, and Inngest runs every planned step in a call of its own. Those calls can run at the same time, on one instance of the app or on several. Once the steps have finished, Inngest calls the function again; the results are memoized and the function continues after `Task.WhenAll`. Figure: When a call reaches two new steps at once, the SDK runs neither. It answers 206 with both as planned steps, Inngest runs each in a call of its own, possibly at the same time and on different instances, and calls the function again once both results are saved. [How it works](https://damikun.github.io/inngest-dotnet/how-it-works.md) shows a parallel section call by call, including the planned steps and the calls that run them. When a call finds a single new step, the SDK runs it right away, as it does for sequential steps, unless Inngest has asked it not to while a parallel section is still running. ## Fan-out over a list Start one step per item and await them together. Give each step an id of its own: ```csharp var invoices = await context.Step.RunAsync("load-invoices", ct => billing.GetOpenInvoicesAsync(customerId, ct)); var paymentIds = await Task.WhenAll(invoices.Select(invoice => context.Step.RunAsync($"charge-{invoice.Id}", ct => billing.ChargeAsync(invoice.Id, ct)))); ``` Every step counts toward the function's [concurrency limit](https://damikun.github.io/inngest-dotnet/guides/flow-control.md#concurrency), so a limit of 5 runs at most five of these charges at once, across all runs of the function. For hundreds of items, consider [invoking a function](https://damikun.github.io/inngest-dotnet/guides/invoking-functions.md) per item, or sending an event per item, so that each item is a run of its own with its own retries. `MapAsync` does this for a list, with ids for the steps, a limit on how many run at once, and a choice of stopping at the first failure or collecting every outcome, and its chunked form sends lists that are too long for one run to child runs. See [Fan-out over a list](https://damikun.github.io/inngest-dotnet/guides/fan-out.md). ## Racing steps `Task.WhenAny` works too. It completes with the step that finished first: ```csharp var primary = context.Step.RunAsync("quote-primary", ct => primaryCarrier.QuoteAsync(shipment, ct)); var backup = context.Step.RunAsync("quote-backup", ct => backupCarrier.QuoteAsync(shipment, ct)); var quote = await await Task.WhenAny(primary, backup); ``` Every planned step still runs to the end; the losing quote is recorded and simply not used. By default Inngest calls the function again only after all the planned steps have finished, so a race picks the first result but does not return it any sooner. The SDK replays results in the order they finished, so the same step wins on every later call. To go on as soon as the first step finishes, run the race in race mode. Every step started inside `ParallelAsync` tells Inngest to call the function again after each of them finishes: ```csharp var quote = await await context.Step.ParallelAsync(ParallelMode.Race, () => Task.WhenAny( context.Step.RunAsync("quote-primary", ct => primaryCarrier.QuoteAsync(shipment, ct)), context.Step.RunAsync("quote-backup", ct => backupCarrier.QuoteAsync(shipment, ct)))); ``` The losing step still runs to the end. The first step after the race runs right away, but the steps after that wait for the losing step to finish, unless they run in race mode too. ## Things to watch - **Different ids in different branches.** Two parallel branches that reuse an id share its numbering, and which branch gets which result can depend on timing. The SDK logs a warning when it sees that. - **Do not block on a step.** `.Result`, `.Wait()` and `GetAwaiter().GetResult()` on a step's task never return: the SDK hands out results on the function's own scheduler, which a blocked thread holds up. - **Leaving the scheduler.** Code that uses `ConfigureAwait(false)` or `Task.Run` inside a function still works, but steps started from other threads may be reported one at a time instead of all at once. - **Unawaited steps still run.** A step that is started and never awaited is reported to Inngest like the others. If the function returns while such steps are new, the SDK reports the steps instead of the output and returns the output on a later call. - **Scoped services.** Each step started together runs in a call of its own, with a dependency injection scope of its own, so parallel steps do not share a `DbContext` that the function injects. Within one call the function's code and its steps do share it; [Scoped steps](https://damikun.github.io/inngest-dotnet/guides/scoped-steps.md) gives a step a scope of its own. - **Inngest's limits.** A run has 1,000 steps by default, parallel ones included, and fails once that many have completed. See the [limits](https://www.inngest.com/docs/durable-execution/limits) page. The Inngest docs describe [parallel steps](https://www.inngest.com/docs/durable-execution/primitives/group-parallel) for the TypeScript SDK, which uses `Promise.all` where .NET uses `Task.WhenAll`. ## Related examples [Fan-out with invoke](https://damikun.github.io/inngest-dotnet/examples/fan-out-with-invoke.md) and the [Connect worker](https://damikun.github.io/inngest-dotnet/examples/connect-worker.md) run steps in parallel. --- # Fan-out over a list > Run a step for each item of a list with a limit on how many run at once, get the results back in order, stop at the first failure or collect every outcome, and send lists too long for one run to child runs in chunks. Web page: https://damikun.github.io/inngest-dotnet/guides/fan-out/ `MapAsync` runs a step for each item of a list and returns the results, as an array like `Task.WhenAll`, in the order of the items. It is `Task.WhenAll` over a list, with an id for every step, a limit on how many steps run at the same time, and a choice of what a failure does. ```csharp var invoices = await context.Step.RunAsync("load-invoices", ct => billing.GetOpenInvoicesAsync(customerId, ct)); string[] paymentIds = await context.Step.MapAsync("charge", invoices, (invoice, ct) => billing.ChargeAsync(invoice.Id, ct), new FanOutOptions { MaxParallelism = 5 }); ``` Each item is a step with the id `charge[0]`, `charge[1]` and so on, retried on its own like any other step. The methods are extensions of `IStepTools`, so they work on `context.Step` and on any fake of it. `ForEachAsync` is the same for a handler without a result: ```csharp await context.Step.ForEachAsync("notify", customerIds, (customerId, ct) => billing.NotifyAsync(customerId, ct)); ``` | You have | Use | | - | - | | A list of up to a few hundred items, and want the results | `MapAsync` | | The same, and want no results | `ForEachAsync` | | The same, and want to know which items failed instead of failing the run | `MapOutcomesAsync` | | More items than one run can hold | `MapInChunksAsync` or `MapOutcomesInChunksAsync`, which run the list in [child runs](https://damikun.github.io/inngest-dotnet/guides/fan-out.md#lists-too-long-for-one-run) | | Work per item that has steps of its own | [`InvokeAsync`](https://damikun.github.io/inngest-dotnet/guides/invoking-functions.md#fan-out-with-results) per item, or an event per item | The handler of an item is the handler of a step, so it cannot call steps. `Parallel.ForEachAsync` is not a replacement: it would run the handlers in the call itself and repeat them on every replay. ## The list has to be the same on every call Inngest runs a function by calling it again for every step, and the code before the fan-out runs each time. The steps of a fan-out are matched to the results Inngest recorded by their position, so the list must come out the same on every call: take it from the event, or from the result of a step, as `load-invoices` does above. A list read outside a step, from a database or from the clock, may differ between calls, and then `charge[3]` gets the result that was recorded for another item. `MapAsync` takes an `IReadOnlyList` on purpose. A lazy `IEnumerable` would run its query again on every call. Give each fan-out in a function an id of its own. With the same id, the second one gets numbered ids like any repeated step id, `charge[0]:1`, and which of two fan-outs that run in parallel is the second can depend on timing. ## How many run at once Without `MaxParallelism` every item starts at once. With it, the first items fill the window, and each time Inngest calls the function again with a result, the next item takes the place of the one that finished: | `MaxParallelism = 2`, five items | Items started | | - | - | | First call | 0 and 1 | | After both have finished | 2 and 3 | | After both have finished | 4 | Inngest calls the function again once all the steps started together have finished, as the [parallel steps](https://damikun.github.io/inngest-dotnet/guides/parallel-steps.md) guide describes, so by default the window moves on in groups: a slow item holds back the next group. `ParallelMode = ParallelMode.Race` has Inngest call the function after each item, and the window moves on as soon as one item has finished, at the cost of more calls: ```csharp var paymentIds = await context.Step.MapAsync("charge", invoices, (invoice, ct) => billing.ChargeAsync(invoice.Id, ct), new FanOutOptions { MaxParallelism = 5, ParallelMode = ParallelMode.Race }); ``` `MaxParallelism` bounds the items of this run. The function's own [concurrency limit](https://damikun.github.io/inngest-dotnet/guides/flow-control.md#concurrency) bounds the steps of all its runs together, and the plan of your Inngest account bounds the steps that run at once overall. ### What a list costs Every call replays the function: the results of all the steps that finished are in the request, and the function reads them one by one before it reaches new work. A list of `n` items with `MaxParallelism = w` takes these calls: | Mode | Calls | | - | - | | Default | For each window of two or more items, one call that finds the items and one call per item. A window of one item runs in the call that finds it, when Inngest allows that. One more call returns the result. | | `Race` | One call that finds the first items, one per item, and one per finished item that calls the function again: `2n + 1`. A step of a race never runs in the call that finds it. | Seven items with `MaxParallelism = 3` are windows of 3, 3 and 1, so `(1 + 3) + (1 + 3) + 1 + 1 = 10` calls, which is what the dev server makes. The replay work and the size of each request grow with the number of results so far, so keep the result of an item small, an id rather than a document, and run long lists in chunks. ## When an item fails An item whose step fails for good, after its retries, throws `StepFailedException` where it is awaited. `MapAsync` and `ForEachAsync` stop at the first one: - no item starts after the failure was seen; - the call throws that item's `StepFailedException`, whose `StepId` is `charge[3]`, without waiting for the items in flight. The SDK cannot recall a step it has planned. By default Inngest calls the function again only once the whole window has finished, so the failure surfaces then; in race mode it surfaces as soon as that item has failed; - when several items failed, the exception is the one Inngest recorded first, the same on every replay. An item that started before the failure was seen is not recalled either. If the function catches the exception and goes on, the SDK still reports that item to Inngest, as it does for any step the function started and did not wait for. ```csharp try { await context.Step.MapAsync("charge", invoices, (invoice, ct) => billing.ChargeAsync(invoice.Id, ct)); } catch (StepFailedException exception) { context.Logger.LogWarning("{StepId} failed: {Error}", exception.StepId, exception.Error.Message); throw; } ``` Left uncaught, the exception fails the run without retries, like any uncaught `StepFailedException`. To keep going and learn which items failed, use `MapOutcomesAsync`. It runs every item and returns one `FanOutOutcome` per item, in the order of the items, instead of throwing: ```csharp var outcomes = await context.Step.MapOutcomesAsync("charge", invoices, (invoice, ct) => billing.ChargeAsync(invoice.Id, ct)); foreach (var outcome in outcomes.Where(outcome => !outcome.Succeeded)) { context.Logger.LogWarning("{Invoice} was not charged: {Error}", invoices[outcome.Index].Id, outcome.Error!.Message); } var paymentIds = outcomes.Where(outcome => outcome.Succeeded).Select(outcome => outcome.Value!).ToList(); ``` `Index` is the position in the list, `Value` is the result, and `Error` is the `FunctionError` the step failed with. Only failed steps become outcomes. Anything else the fan-out meets, such as a result that cannot be read as the item's type, is thrown. ## Cancellation Every method takes a `CancellationToken`. Once it is canceled, the next item does not start, and the call throws `OperationCanceledException`. Items already planned are not recalled. The token has to be canceled the same way on every replay, or two calls start different items. Cancel it from what the function decides from the results of steps, and not from a timer or another thread. The token of the call, which Inngest cancels when it abandons the call, is safe to pass and changes nothing, since the call ends then anyway. ## Lists too long for one run Inngest fails a run once 1,000 of its steps have completed, so a run holds at most 999, and every step of the run counts: the items of a fan-out, the other steps of the function and, in the parent of chunks, the invoke steps. On the dev server a fan-out of 999 items completes, and one of 1,000 fails with "exceeded the step limit of 1000 steps" after all its steps ran. `MapAsync` therefore refuses 1,000 items or more with a `NonRetriableException`, since a retry would meet the same list, and fewer fit when the function has other steps. A longer list goes to child runs in chunks: the parent invokes one function per chunk, and each of those runs has a step for every item of its chunk. The limits that decide the sizes are below. This SDK checks only the number of steps; the rest is for choosing sizes. The last column says what was tried against the dev server (`inngest/inngest:v1.49.0`) while this page was written. Nothing was tried on Inngest Cloud. | Limit | Value | What it bounds | Tried on the dev server | | - | - | - | - | | Steps in a run | 1,000, [documented](https://www.inngest.com/docs/durable-execution/limits) | The items of a fan-out, the size of a chunk, the number of chunks | 999 steps complete, 1,000 fail | | Result of a step | 4 MiB, documented | The result of one item | Not tried | | Response of the app | 8 MiB (`output_too_large`), not documented | The items of all the chunks planned in one call, and the outcomes a chunk's run ends with | Items of 3.5 MB accepted, of 10 MB refused; outcomes of 7.8 MB accepted, of 11.7 MB refused after the items ran | | Size of an event | 256 KiB on Free, 3 MiB on Pro and Business, documented | A bound for the items of one chunk, which are the data of the event that starts its run | Chunks of 300 KiB, 1 MiB and 3.5 MB accepted | | State of a run | 32 MiB, documented | The outcomes of all the chunks, which land in the parent's state | Not tried | Inngest documents no separate limit for the payload of an invoke. The dev server did not apply the limit of events to it, and what Inngest Cloud does is not verified here, so use the event size of your plan as a conservative bound. ### The function of a chunk The app provides the child by deriving from `FanOutFunction` and implementing `ProcessAsync` for one item. The base class runs it for each item of its chunk, as a step of its own with the id `item[index]`, and returns the outcomes. Register the class like any function, with an id and no trigger, because only the fan-out starts it: ```csharp title="ChargeInvoices.cs" using Inngest; [InngestFunction("charge-invoices", Retries = 3)] [Concurrency(10)] public sealed class ChargeInvoices(IBilling billing) : FanOutFunction { // How many items of a chunk run at the same time. The default is all of them. protected override int? MaxParallelism => 5; protected override Task ProcessAsync(Invoice invoice, CancellationToken cancellationToken) => billing.ChargeAsync(invoice.Id, cancellationToken); } ``` #### Attributes ```csharp inngest.AddFunction(); ``` #### Code first ```csharp inngest.AddFunction(new FunctionOptions { Id = "charge-invoices", Triggers = [], Retries = 3, Concurrency = [new ConcurrencyLimit(10)], }); ``` The options of the function are those of the chunk's run. Its `Retries` are the retries of each item's step, and a concurrency limit bounds the steps of all the chunks at once. ### Running the chunks The parent names the class and the types, and says how many items go to a chunk: ```csharp string[] paymentIds = await context.Step.MapInChunksAsync( "charge", invoices, chunkSize: 100, new FanOutOptions { MaxParallelism = 4 }); ``` The parent has an invoke step for every chunk, with the id `charge[0-99]`, `charge[100-199]` and so on, which holds the position of both ends so that a chunk size changed between two calls cannot give a chunk the result of another. `MaxParallelism` is the number of chunks in flight, and `ChunkTimeout` is how long a chunk's run may take; without it Inngest waits as long as its [default for an invoke](https://www.inngest.com/docs/reference/typescript/v4/functions/step-invoke), a year. A chunk that times out fails its step and its run goes on. Failures work as in `MapAsync`, with the difference that the failing step ran in another run: - a chunk stops starting items once one has failed for good, and the parent starts no more chunks. The call throws a `StepFailedException` whose `StepId` is `charge[137]`, the position of the item, and whose `Error` is the item's error; - a chunk whose run fails or times out throws a `StepFailedException` with the id of the chunk's step; - `MapOutcomesInChunksAsync` runs every item and returns an outcome for each. When a chunk's run fails or times out, each of its items gets the error of the chunk's step, although that run may have processed some or all of them. The parent cannot tell, so retrying the items it reports as failed can process an item twice; make `ProcessAsync` safe to repeat. Two more things to know. A parent that fails does not cancel the chunk runs it has started; they run to the end and their results are dropped. And a `chunkSize` changed between two calls of a run never mixes results, because the ids carry both ends, but no chunk then has the id it had, so every chunk is invoked again and its items run again. Keep the chunk size the same for runs that are in flight. ### Choosing the sizes What decides the sizes in practice is what has to fit: - **The items of the chunks planned together fit in one response.** The call that plans chunks answers with all of them, so `chunkSize` times the size of an item as JSON, times the chunks in flight (`MaxParallelism`, or all of them), should stay well under 8 MiB. One chunk should also stay under the event size of your plan. - **The outcomes of a chunk fit in the response that ends its run.** An outcome is the result as JSON in `{"index":…,"value":…}`, which adds about 25 bytes to it. Each result is a step result, at most 4 MiB. - **All the outcomes fit in the parent's state.** They add up over the chunks, with the event and the other steps, to 32 MiB at most. Keep the result of an item small, a few numbers or an id. For work with no result, return a `bool`. - **The items themselves are small.** A list that is too big for a response is a list of ids or ranges, such as page numbers, and `ProcessAsync` loads what it needs. The call refuses a `chunkSize` of 1,000 or more, and a list that makes 1,000 chunks or more, with a `NonRetriableException`. A list beyond that is split between several runs, for example by an event per part. Under Native AOT or trimming, add the chunk and its outcomes to your `JsonSerializerContext`, for each function of a chunk. The engine writes the output of a function by its runtime type, which is why the outcomes are an array and the context lists the array. See [Native AOT](https://damikun.github.io/inngest-dotnet/guides/native-aot.md): ```csharp using System.Text.Json.Serialization; using Inngest; [JsonSerializable(typeof(FanOutChunk))] [JsonSerializable(typeof(FanOutOutcome[]))] internal sealed partial class FanOutJson : JsonSerializerContext; ``` ## Checkpointing With checkpointing on (`CheckpointingOptions`, see the [API overview](https://damikun.github.io/inngest-dotnet/reference/api.md#options)), steps that follow one another run in one call. A fan-out with `MaxParallelism = 1` is such a sequence, and runs in a single call up to the checkpointing `MaxRuntime`. A window of several items is a parallel group, which goes back to one call per step. ## Testing Mock the steps of the items by their ids, `charge[0]`, `charge[1]`, with the [test host](https://damikun.github.io/inngest-dotnet/guides/testing.md): ```csharp using Inngest.Testing; var mocks = new StepMocks() .Run("charge[0]", "pay_1") .Run("charge[1]", "pay_2"); var run = await host.RunAsync("settle-invoices", new InngestEvent("billing/settle", new { customerId = "acme" }), mocks); ``` A parent that runs chunks mocks the invoke steps by their ids and class. The host runs one function at a time, so run the function of the chunk with its own event, a `FanOutChunk` named `inngest/function.invoked`, and hand its output to the parent's mock: ```csharp using Inngest.Testing; var chunk = await host.RunAsync( new InngestEvent("inngest/function.invoked", new FanOutChunk(0, firstChunk, FailFast: true))); var mocks = new StepMocks().Invoke[]>( "charge[0-99]", chunk.GetOutput[]>()!); ``` A fake of `IStepTools` that runs handlers on the spot, as in [the testing guide](https://damikun.github.io/inngest-dotnet/guides/testing.md#a-fake-for-the-step-tools), runs `MapAsync` too. If the test is about failures, the fake has to throw `StepFailedException` for a handler that failed, as Inngest reports it. ## Related [Parallel steps](https://damikun.github.io/inngest-dotnet/guides/parallel-steps.md) explains how Inngest runs steps started together, [Invoking functions](https://damikun.github.io/inngest-dotnet/guides/invoking-functions.md) the child runs, and [Fan-out with invoke](https://damikun.github.io/inngest-dotnet/examples/fan-out-with-invoke.md) one function per item with its own flow control. The Inngest docs describe [parallel steps](https://www.inngest.com/docs/durable-execution/primitives/group-parallel) and [fan-out with events](https://www.inngest.com/docs/durable-execution/guides-and-advanced/patterns/fan-out). --- # Sleep and wait for events in a workflow > Pause a run for a duration, until a point in time, or until a matching event arrives, without holding a thread, a request or a worker. Web page: https://damikun.github.io/inngest-dotnet/guides/sleep-and-wait-for-events/ A paused run costs nothing while it waits. The SDK tells Inngest what the run is waiting for and the call ends. Inngest calls the function again when the time comes or the event arrives, and the function continues after the step. The app can be restarted, scaled down or redeployed in between. ## Sleep for a duration ```csharp await context.Step.RunAsync("send-welcome", ct => email.SendAsync(context.Event.Data.Email, "Welcome!", ct)); await context.Step.SleepAsync("wait-three-days", TimeSpan.FromDays(3)); await context.Step.RunAsync("send-check-in", ct => email.SendAsync(context.Event.Data.Email, "How is it going?", ct)); ``` Durations are sent to Inngest in whole seconds, rounded down; one under a second, other than zero, becomes one second. A single sleep can last up to a year, within your plan's maximum run length; see Inngest's [limits](https://www.inngest.com/docs/durable-execution/limits) and [sleep](https://www.inngest.com/docs/durable-execution/primitives/step-sleep) pages. ## Sleep until a time `SleepUntilAsync` takes a `DateTimeOffset`. The SDK sends it to Inngest in UTC, so the offset you use does not matter: ```csharp var reminder = await context.Step.RunAsync("load-reminder", ct => reminders.GetAsync(context.Event.Data.GetProperty("reminderId").GetString()!, ct)); await context.Step.SleepUntilAsync("wait-until-due", reminder.DueAt); await context.Step.RunAsync("notify", ct => reminders.NotifyAsync(reminder.Id, ct)); ``` The time comes from a step, which keeps it the same on every call. A time computed outside a step, such as `DateTimeOffset.UtcNow.AddHours(1)`, would be different on each call and the step would move. To delay the start of a whole function rather than part of it, send the triggering event with a future `Timestamp`. See [Sending events](https://damikun.github.io/inngest-dotnet/guides/sending-events.md). ## Wait for an event `WaitForEventAsync` pauses until a matching event arrives, or until the timeout passes. It returns the event, or `null` on timeout: ```csharp var user = context.Event.Data; var activated = await context.Step.WaitForEventAsync("wait-for-activation", TimeSpan.FromDays(3), match: "data.userId"); if (activated is null) { await context.Step.RunAsync("send-reminder", ct => email.SendAsync(user.Email, "Your account is waiting", ct)); } ``` `UserActivated` is a [typed event](https://damikun.github.io/inngest-dotnet/guides/sending-events.md#typed-events), so the wait takes the event's name from the type and reads its data into it. For an event without a record of its own, name it in `WaitForEventOptions`; the type argument is then only the data type, and leaving it out gives a `JsonElement`: ```csharp var reply = await context.Step.WaitForEventAsync("wait-for-reply", new WaitForEventOptions("support/ticket.replied", TimeSpan.FromDays(3)) { Match = "data.userId" }); ``` The timeout is required, so a run can never wait forever. ### Matching the right event Without a condition, any `user/activated` event resumes the wait, whoever it is for. Two options narrow it down: - `Match` names a property path that must be equal in the triggering event and the awaited one. `"data.userId"` becomes the expression `event.data.userId == async.data.userId`. - `If` is a full [expression](https://www.inngest.com/docs/guides/writing-expressions). `event` is the event that triggered the run and `async` is the candidate event: ```csharp new WaitForEventOptions("billing/invoice.paid", TimeSpan.FromDays(14)) { If = "async.data.invoiceId == event.data.invoiceId && async.data.amount >= event.data.amount", } ``` When both are set, both apply: the SDK joins them with `&&`. The typed form takes them as the `match` and `@if` arguments. ### Only events after the wait starts A wait sees events that arrive after it starts, not earlier ones. If the event can come before the function reaches the wait, for example because users often activate their account within seconds of signing up, wait as early in the function as you can, or check in a step whether it already happened before you wait. The Inngest docs on [waitForEvent](https://www.inngest.com/docs/durable-execution/primitives/step-waitforevent) cover the details. ## Waiting does not hold capacity A sleeping or waiting run does not use a concurrency slot, a thread or a connection. Thousands of runs can wait at once. If a waiting run should stop instead, for example when the user deletes their account, cancel it with an event: see [cancellation](https://damikun.github.io/inngest-dotnet/guides/flow-control.md#cancel-on-events). Figure: While a run sleeps or waits, Inngest holds it and the app runs nothing for it: no thread, timer or connection. Inngest calls the function again when the time has passed, when the event arrives, or at the timeout. ## What Inngest stores A finished sleep is memoized as `null`, and a wait as the matching event or `null`. Replays return those values, so code after a wait sees the same event on every call. ## Related examples [User onboarding](https://damikun.github.io/inngest-dotnet/examples/user-onboarding.md) waits for an activation event, and [scheduled reminders](https://damikun.github.io/inngest-dotnet/examples/scheduled-reminders.md) sleep until a due date. --- # Invoking functions from other functions > Run another Inngest function from a step with InvokeAsync, by its class or its id, pass it data, get its typed result back, and handle failures and timeouts. Web page: https://damikun.github.io/inngest-dotnet/guides/invoking-functions/ `InvokeAsync` runs another function and waits for its output, as a step. The other function runs as a run of its own, with its own retries, flow control and history, and its output comes back as the step's result. ```csharp var score = await context.Step.InvokeAsync("score-acme", new ScoreRequest("acme"), timeout: TimeSpan.FromMinutes(5)); context.Logger.LogInformation("acme scored {Score}", score.Score); ``` `ScoreAccount` is the class of the function to run. The step looks up the id the app registered it under, from options in code or from its attributes, and the compiler checks that `ScoreAccount` returns an `AccountScore`. ## The invoked function Any function can be invoked. Give it a result type with `InngestFunction`, and one that should only ever be invoked has no trigger: ```csharp title="ScoreAccount.cs" using Inngest; public sealed record ScoreRequest(string AccountId); public sealed record AccountScore(string AccountId, int Score); public sealed class ScoreAccount(IScoring scoring) : InngestFunction { public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) { var accountId = context.Event.Data.AccountId; var score = await context.Step.RunAsync("compute-score", ct => scoring.ScoreAsync(accountId, ct)); return new AccountScore(accountId, score); } } public interface IScoring { Task ScoreAsync(string accountId, CancellationToken cancellationToken); } ``` ### Attributes ```csharp [InngestFunction("score-account")] ``` The attribute goes on `ScoreAccount`, and `inngest.AddFunction()` registers it. ### Code first ```csharp inngest.AddFunction(new FunctionOptions { Id = "score-account", Triggers = [] }); ``` Inngest starts the invoked run with an `inngest/function.invoked` event whose `data` is the data you passed, so the invoked function reads it as its event data. Its return value is what `InvokeAsync` returns. A class that derives from `InngestFunction` returns `object?`, so a caller can only read its result as `object`. Give it a result type, or invoke it by its id. ## By id To invoke a function of another app, a delegate, or a function whose class the caller cannot reference, name it by the id it was registered with. The type argument is then only the type to read its output as: ```csharp var score = await context.Step.InvokeAsync("score-acme", new InvokeOptions("score-account") { Data = new { accountId = "acme" }, Timeout = TimeSpan.FromMinutes(5) }); ``` ## Options | Option | Meaning | | - | - | | `data` | The invoked run's event data. Serialized with the app's serializer options. In `InvokeOptions`, `Data`. | | `timeout` | How long to wait for the invoked function. When it passes, the step fails; the invoked run keeps going. In `InvokeOptions`, `Timeout`. | | `FunctionId` | `InvokeOptions` only: the id of the function to run, as registered. | | `AppId` | `InvokeOptions` only: the app the function belongs to. Defaults to the current app, so set it only to invoke a function of another app in the same Inngest environment. | | `User` | `InvokeOptions` only: optional user data for the invoked run's event. | Without a timeout Inngest waits a long time; see [step.invoke](https://www.inngest.com/docs/durable-execution/primitives/step-invoke) in the Inngest docs for its default. ## Fan-out with results Invoke one function per item and await them together. Each invocation is a separate run, so each item gets its own retries, and the invoked function's concurrency limit caps how many run at once: ```csharp string[] accountIds = ["acme", "globex", "initech"]; var scores = await Task.WhenAll(accountIds.Select(accountId => context.Step.InvokeAsync($"score-{accountId}", new ScoreRequest(accountId), timeout: TimeSpan.FromMinutes(5)))); await context.Step.SendEventAsync("announce-scores", new InngestEvent("reports/scores.ready", new { total = scores.Sum(score => score.Score) })); ``` The [fan-out example](https://damikun.github.io/inngest-dotnet/examples/fan-out-with-invoke.md) builds on this. For a long list, [`MapInChunksAsync`](https://damikun.github.io/inngest-dotnet/guides/fan-out.md#lists-too-long-for-one-run) invokes one function per chunk of items and gathers the results in order. ## When the invoked function fails If the invoked function fails all its attempts, or the timeout passes first, the step fails at once without retries of its own, and `InvokeAsync` throws `StepFailedException`. Retrying the step would run the whole invoked function again, and in a chain of invocations the retries would multiply. Catch the exception to carry on without that result: ```csharp AccountScore? score; try { score = await context.Step.InvokeAsync("score-acme", new ScoreRequest("acme"), timeout: TimeSpan.FromMinutes(5)); } catch (StepFailedException exception) { context.Logger.LogWarning("Scoring failed: {Error}", exception.Error.Message); score = null; } ``` Left uncaught, `StepFailedException` fails the run without retrying it. If the invoked function's rate limit skips the run, the step fails in the same way. ## Passing the user on An invoked function does not receive the user who caused the calling run by itself, because invocations do not pass through middleware. With [`Inngest.UserContext`](https://damikun.github.io/inngest-dotnet/guides/user-context.md#pass-the-user-on), `WithUser` adds the run's user to the options. ## Invoke or send an event? Invoke when the caller needs the result or must know that the work finished. Send an event with [`SendEventAsync`](https://damikun.github.io/inngest-dotnet/guides/sending-events.md) when the caller should not wait, or when any number of functions may react to what happened. --- # Sending events from .NET to Inngest > Send events to Inngest from application code with IInngestClient and from functions with SendEventAsync, with ids for deduplication, delayed starts and retries. Web page: https://damikun.github.io/inngest-dotnet/guides/sending-events/ Events start functions. An event has a name and JSON data, and every function with a matching trigger gets a run. There are two ways to send one: `IInngestClient` from ordinary application code, and `context.Step.SendEventAsync` from inside a function. ## Typed events An event's data is usually a record. Implement `IInngestEventData` on it to give the record the event's name, and code can send the record itself: ```csharp title="Events.cs" using Inngest; public sealed record OrderPlaced(string OrderId, string CustomerId, decimal Total) : IInngestEventData { public static string EventName => "shop/order.placed"; } public sealed record LabelRequested(string OrderId) : IInngestEventData { public static string EventName => "shipping/label.requested"; // Optional: the version of the event's schema, sent as "v". public static string? EventVersion => "2026-10-01"; } ``` The functions that run on the event name the type as well, with `Trigger.Event()` or `[EventTrigger]`. The name can then no longer drift from the data it carries, and a typo is a compile error. Everything below also works with an `InngestEvent` that names the event as a string, for events without a record of their own. ## From application code `AddInngest` registers `IInngestClient` as a singleton. Inject it anywhere: ```csharp title="CheckoutService.cs" using Inngest; public sealed record Order(string Id, string CustomerId, decimal Total); public sealed class CheckoutService(IInngestClient inngest, ILogger logger) { public async Task PlaceOrderAsync(Order order, CancellationToken cancellationToken) { // Save the order first, then announce it. If the app dying in between must not lose the event, use the // transactional outbox, which saves the event with the order. var sent = await inngest.SendAsync(new OrderPlaced(order.Id, order.CustomerId, order.Total), cancellationToken); logger.LogInformation("Order {OrderId} placed as event {EventId}", order.Id, sent.Ids[0]); } } ``` `SendAsync` returns the ids Inngest gave the events, in the order they were sent. To send several events in one request, pass a list: ```csharp await inngest.SendAsync(orders.Select(order => new OrderPlaced(order.Id, order.CustomerId, order.Total)), cancellationToken); ``` To set an id, a timestamp or user data, wrap the record in an `InngestEvent`, as the sections below do. Take its name from the type: ```csharp await inngest.SendAsync(new InngestEvent(OrderPlaced.EventName, new OrderPlaced(order.Id, order.CustomerId, order.Total)) { Id = $"order-placed-{order.Id}", }, cancellationToken); ``` ## From a function Inside a function, send events with the step tool. The send is a step, so it happens once per run: a replay returns the saved ids instead of sending the events again. ```csharp var order = context.Event.Data; await context.Step.SendEventAsync("request-shipping", new LabelRequested(order.OrderId)); ``` The step takes a list of records too, or `InngestEvent`s for events named by a string. Sending through `IInngestClient` inside a function would send again on every call that passes that line. Use the step tool there. Events sent from a run carry the run's sessions, and with [`Inngest.UserContext`](https://damikun.github.io/inngest-dotnet/guides/user-context.md) the user who caused it. ## What an event holds `InngestEvent` is a record with the name and the data, plus optional properties. A typed send creates one with the type's name and version: | Property | Meaning | | - | - | | `Name` | The event name, such as `shop/order.placed`. Names that start with `inngest/` are reserved and rejected. | | `Data` | Any object; serialized with the app's serializer options, so property names are camel case by default. | | `Id` | A deduplication key. Inngest ignores a second event with the same id for 24 hours. | | `Timestamp` | When the event happened. Defaults to when Inngest receives it. A time in the future delays the runs it starts. | | `User` | Optional user data. | | `Version` | An optional version of the event's schema, sent as `v`. A typed event takes it from `EventVersion`. | | `Meta` | The [sessions](https://damikun.github.io/inngest-dotnet/guides/sending-events.md#sessions) the event belongs to. | ### Deduplicating with an id Set `Id` to something that identifies the occurrence, not the attempt to send it: an order id, a webhook delivery id, a request id. If the same thing is sent twice, by a retry or a second server, Inngest keeps the first and starts no new runs for the second. Ids are global across event names, so include the name or a prefix. The [transactional outbox](https://damikun.github.io/inngest-dotnet/guides/outbox.md) relies on this to send an event again safely. The Inngest docs explain [event ids and deduplication](https://www.inngest.com/docs/durable-execution/guides-and-advanced/events-and-triggers/send-events). [Webhooks](https://damikun.github.io/inngest-dotnet/guides/webhooks.md) from Stripe, GitHub, Shopify and others arrive as events whose id is the provider's id for the delivery, so a delivery that is sent again starts no second run. ### Delaying runs A future `Timestamp` makes the functions an event triggers start at that time: ```csharp await inngest.SendAsync(new InngestEvent("trial/ending-soon", new { userId }) { Timestamp = DateTimeOffset.UtcNow.AddDays(12), }, cancellationToken); ``` Inside a function, sleep instead; see [Sleep and wait for events](https://damikun.github.io/inngest-dotnet/guides/sleep-and-wait-for-events.md). ## Sessions Sessions group runs by the work they belong to, such as a support ticket or a conversation, so the Inngest UI can show them together. An event names its sessions as key and id pairs, up to five: ```csharp await inngest.SendAsync(new InngestEvent("support/ticket.opened", new { TicketId = "ticket_42" }) { Meta = new EventMeta { Sessions = new Dictionary { ["ticket_id"] = "ticket_42", ["user_id"] = "user_7" }, }, }); ``` A run's sessions are those every triggering event belongs to, at most five, in `context.Sessions`. Events the run sends, from a send step or through `IInngestClient`, and functions it invokes inherit them, so the whole chain of work stays in the session. An event's own sessions win over inherited ones, and a null id drops an inherited session; `InheritSessions = false` sends an event without any. `InvokeOptions.Meta` does the same for an invocation: ```csharp await context.Step.InvokeAsync("summarize", new InvokeOptions("summarize-conversation") { Meta = new EventMeta { Sessions = new Dictionary { ["user_id"] = null } }, }); ``` `InngestOptions.SessionPropagation = false`, or `INNGEST_SESSION_PROPAGATION=false`, turns inheriting off for the app; `context.Sessions` is then empty. A received event's sessions are in `Meta.Sessions`, also on the event a wait matched. Waits do not match on sessions. Middleware sees the inherited sessions in `Meta.PropagatedSessions` and may remove some before an event is sent. ## Keys and modes In production the client needs an event key: set `INNGEST_EVENT_KEY` or `InngestOptions.EventKey`. Without one, `SendAsync` throws an `InngestException`. In dev mode no key is needed; the dev server accepts any. See [Configuration](https://damikun.github.io/inngest-dotnet/guides/configuration.md). ## Failures and retries The client retries a send that fails with a network error, a timeout, a `5xx`, `408`, `425` or `429`, up to five attempts, waiting 100 ms, 200 ms, 400 ms and so on with some jitter in between. Other errors, such as `401` for a wrong key or `400` for an invalid event, fail at once. Every attempt of one `SendAsync` call carries the same seed for event ids, so a retry after a lost response creates the same events rather than copies. When the send fails for good, `SendAsync` throws `InngestException`, with the HTTP status in `StatusCode` when there was one. Inside a function, `SendEventAsync` fails like any other step and is retried with it. ```csharp try { await inngest.SendAsync(new InngestEvent("shop/cart.abandoned", new { cartId = "c_42" }), cancellationToken); } catch (InngestException exception) when (exception.StatusCode is 401) { logger.LogError(exception, "The Inngest event key is wrong or missing."); throw; } ``` ## Changing events in one place Middleware can see and change every event the app sends, from code and from functions, for example to add tracing data or a tenant id. See `WrapSendEventAsync` in [Middleware](https://damikun.github.io/inngest-dotnet/guides/middleware.md). Inngest limits the size of an event and the number of events per request; the [limits](https://www.inngest.com/docs/durable-execution/limits) page lists them. ## Related examples [Nightly billing](https://damikun.github.io/inngest-dotnet/examples/nightly-billing.md) fans out with `SendEventAsync`, and the [webhook pipeline](https://damikun.github.io/inngest-dotnet/examples/webhook-pipeline.md) sends one event per delivery. --- # Transactional outbox > Save events in the same database transaction as your data with Inngest.EntityFrameworkCore, and let a hosted dispatcher send them to Inngest afterwards, under the same id on every attempt. Web page: https://damikun.github.io/inngest-dotnet/guides/outbox/ Sending an event right after saving data can go wrong in two ways. If the process dies between the save and the send, the data is stored and the event is lost. If the event is sent first and the save fails, Inngest starts functions for work that never happened. `IInngestClient.SendAsync` cannot be part of a database transaction. The outbox of `Inngest.EntityFrameworkCore` makes the event part of the transaction. `AddInngestEvent` adds a row to a table of your database through the same `DbContext`, so `SaveChanges` stores the data and the event together, or neither. A hosted dispatcher reads the table and sends the events to Inngest after the commit. ## Set it up Add the package. The `net8.0` build is made for Entity Framework Core 8 and the `net10.0` build for Entity Framework Core 10. Entity Framework Core 9 is not supported: an app on .NET 9 gets the `net8.0` build, which needs Entity Framework Core 8, and with Entity Framework Core 9 the host refuses to start and says so. ```bash dotnet add package Inngest.EntityFrameworkCore ``` Map the table in the model of the `DbContext` that holds your data, and add a migration for it as for any other table (`dotnet ef migrations add AddInngestOutbox`): ```csharp title="ShopContext.cs" using Microsoft.EntityFrameworkCore; public sealed class Order { public int Id { get; set; } public string Number { get; set; } = string.Empty; public string CustomerId { get; set; } = string.Empty; public decimal Total { get; set; } } public sealed class ShopContext(DbContextOptions options) : DbContext(options) { public DbSet Orders => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) => // Maps the InngestOutbox table next to the app's own. modelBuilder.AddInngestOutbox(); } ``` Register the outbox on the Inngest builder, and add `UseInngestOutbox` to the options of the context: ```csharp title="Program.cs" using Inngest; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); builder.Services.AddInngest(options => options.AppId = "shop") .AddEntityFrameworkOutbox(); builder.Services.AddDbContext(options => options .UseNpgsql(builder.Configuration.GetConnectionString("Shop")) .UseInngestOutbox()); var app = builder.Build(); app.MapInngest(); app.Run(); ``` `AddEntityFrameworkOutbox` adds the hosted dispatcher of that context. Call it for each `DbContext` that has events to send; each has its own table, settings and dispatcher. `UseInngestOutbox` is an interceptor that makes the events leave sooner: it wakes the dispatcher when a save that added events has committed, where without it they leave at the next poll, 5 seconds later by default. It finds the dispatcher through the application's services that Entity Framework leaves in the options of the context, so it can be called wherever the options are built, in the callbacks of `AddDbContext` and `AddDbContextPool` with or without a service provider, of `AddDbContextFactory`, and in `OnConfiguring`. When the dispatcher starts, it says once, at the information level, if the context has no `UseInngestOutbox`. `UseInngestOutbox(services)` does the same and also fails when the options are built if the outbox is not registered. ## Add events to a unit of work `AddInngestEvent` takes a record that implements `IInngestEventData`, the same records that `SendAsync` takes. It sends nothing: the event is a row in the unit of work that `SaveChangesAsync` stores. ```csharp title="CheckoutService.cs" using Inngest; using Microsoft.EntityFrameworkCore; public sealed class CheckoutService(ShopContext db) { public async Task PlaceOrderAsync(Order order, CancellationToken cancellationToken) { db.Orders.Add(order); db.AddInngestEvent(new OrderPlaced(order.Number, order.CustomerId, order.Total)); // The order and the event are stored together, or neither is. await db.SaveChangesAsync(cancellationToken); } } ``` The data is serialized when the event is added, with `InngestOptions.SerializerOptions`, as `SendAsync` does, so what the SDK could send can be added, and what it could not fails here, with the code that added the event still on the stack: a name that is empty or reserved, data that is not a JSON object, a type that the serializer options have no metadata for in an app that trims. In an app that trims, add the event types to a source-generated context in the options, as for `SendAsync`. The method returns the row. Its `Id` is the id the event is sent under, and the timestamp of the event is the time `AddInngestEvent` ran, not the time the dispatcher got to it. For events named by a string, or to set the user data, the version, the sessions or the timestamp, add an `InngestEvent`: ```csharp using Inngest; using Microsoft.EntityFrameworkCore; db.AddInngestEvent(new InngestEvent("support/ticket.opened", new { TicketId = "t_42" }) { Id = "ticket-opened-t_42", Version = "2026-10-01", }); // The id of a typed event is the second argument. db.AddInngestEvent(new OrderPlaced(order.Number, order.CustomerId, order.Total), id: $"order-placed-{order.Number}"); ``` Without an `Id`, the event gets a generated one: a ULID, the format of the ids Inngest gives its own events. Give one when the event stands for something the code can name, such as an order. The column is the primary key of the table, so a second attempt at the same work fails the save with a `DbUpdateException` instead of announcing the order twice. The key is checked against the rows that are still in the table, which are kept for the retention time. - The id, the name and the version are limited to 255 characters. Longer ones fail when the event is added. - A database that compares text without regard to case, which is the default of SQL Server and MySQL, takes two ids that differ only in case for the same key, so the second one fails the save. PostgreSQL and SQLite tell them apart. - An event added inside a function does not carry the sessions of the run (`FunctionContext.Sessions`), because the dispatcher sends it outside the run. Set `Meta.Sessions` on the event if it should belong to a session. ## How events are sent The dispatcher of each context repeats three steps. It starts when the host starts and takes no more work when it stops. 1. **Claim.** It takes up to `BatchSize` events that are due. The claim is an `UPDATE` through `ExecuteUpdate` that writes a token of its own and an expiry (`LeaseDuration`) on those rows, and then it reads the rows that hold its token. Nothing is locked, so any number of dispatchers, in one process or in many, share the table and an event is claimed by one of them at a time. Nothing in it is specific to a provider. 2. **Send.** The whole batch goes to Inngest in one request, each event under its own id. 3. **Record.** The events Inngest took get `SentAt`. The others stay, with `Attempts` counted, `LastError` set and `NextAttemptAt` moved on. Three things start a pass: a save that added events and has committed, the poll interval, and the end of a pass that found events, which may have left more behind. When the save used a transaction of the code's own, the wake-up comes with its commit, not with the save. With a `TransactionScope` it comes at the save, before the scope completes, so the dispatcher finds nothing and the events leave at the next poll. A transaction that is committed outside Entity Framework Core gives no wake-up, and the poll finds the events. ### At least once, and the repeat of an id A dispatcher can send a batch and die before it records that, or lose the connection to the database at that point. The events are then still due when their lease runs out, and another dispatcher sends them again. Delivery is at least once. An event keeps the id it was added with, on every attempt, so the repeat is an event with an id that Inngest has seen. Inngest's documentation says it drops a repeated id for 24 hours. What was checked here: against the dev server (`inngest/inngest:v1.49.0`) a send that repeated an id seconds after the first was answered with a success and an event id of its own, and the function it triggers started no run. The dev server's source, read at the v1.49.0 tag, keeps the key of a run for 24 hours. Not checked: Inngest Cloud, a repeat after hours, and whether a repeat can also resume a run that waits for the event. A resend that comes later than 24 hours, which takes a dispatcher that was down for as long, would start runs again. ### When sending fails An event is never given up and never deleted before it is sent. A failed request leaves its events in the table, and each is tried again after `RetryDelay`, then after twice that, and so on up to `MaxRetryDelay`, counted per event from its own failures. `Attempts` and `LastError` say how often and why. When Inngest turns a request down because of its events (400, 409, 412, 413 or 422), for example an event that is too large, the events of that request are sent one at a time, so that the one it objects to does not hold back the others. Each event that is refused stays in the table and is tried again at the retry interval, with an error in the log at every attempt that names its id and its name. It will not go through until the row is fixed or deleted. If Inngest is down or the event key is wrong, the events are not split: they fail together, as one request. Events that are too large for a request together, but not alone, are sent one at a time after every refused batch, so lower `BatchSize` for large events. A request that takes longer than `SendTimeout` counts as failed. When the host stops, the events a dispatcher had claimed are given back at once and do not cost an attempt, so another instance sends them without waiting for the lease. ### Order Events are sent oldest first within a batch, but nothing keeps one event from arriving after a later one: batches go out from several dispatchers, and a failed event waits while its neighbours pass. Inngest does not promise an order between events either. If a function needs one, put a sequence number or a version in the data. ### Clocks The instances compare their own clocks with times that other instances wrote: when a lease ends, when a retry is due and whether a new event is due. Keep the clocks in step. A dispatcher whose clock runs ahead takes events from one that is still sending them only if it is ahead by more than `LeaseDuration` minus `SendTimeout`, which is 40 seconds with the defaults, and a repeat is dropped by its id in any case. An event saved by an instance whose clock runs ahead, and that runs no dispatcher itself, is not due for the dispatchers of the others until their clocks reach its time, so it waits as long as the clock is ahead. The `ts` it is sent with is that clock's time too. ### Cleanup Events that Inngest took stay in the table for `Retention`, one day by default, and are then deleted in batches of `CleanupBatchSize` with `ExecuteDelete`. The retention time keeps an id that the code chose from being added again while Inngest still drops it, so it should not be shorter than the time Inngest remembers ids. Events that are not sent are never deleted. ## Options The settings are the same for every instance of the app, because each runs a dispatcher. They are checked when the host starts. ```csharp inngest.AddEntityFrameworkOutbox(options => { options.PollInterval = TimeSpan.FromSeconds(2); options.BatchSize = 200; options.Retention = TimeSpan.FromDays(2); }); ``` | Option | Default | Meaning | | - | - | - | | `DispatcherEnabled` | `true` | Runs the dispatcher in this process. Turn it off in a process that only adds events, and keep it on in at least one other. | | `PollInterval` | 5 seconds | How long the dispatcher waits for a wake-up before it looks anyway. This is how it finds events that other processes saved and retries that fell due. | | `BatchSize` | 100 | The most events one claim takes and one request sends. | | `LeaseDuration` | 1 minute | How long a dispatcher owns what it claimed. After a crash its events wait this long. At least twice `SendTimeout`, so that a request Inngest refuses can still be followed by requests for single events. | | `SendTimeout` | 20 seconds | How long one request may take, the retries of the SDK's client included. | | `RetryDelay` | 5 seconds | The wait after an event's first failed attempt. Each further failure doubles it. | | `MaxRetryDelay` | 5 minutes | The longest wait between attempts. | | `Retention` | 1 day | How long sent events stay before they are deleted. | | `CleanupInterval` | 5 minutes | How often sent events past their retention time are deleted. | | `CleanupBatchSize` | 1000 | How many events one `DELETE` removes. | ## Look at the table The rows are `InngestOutboxMessage` objects. The table shows what is waiting and why: ```csharp using Microsoft.EntityFrameworkCore; var cutoff = DateTime.UtcNow.AddMinutes(-5); var stuck = await db.Set() .Where(message => message.SentAt == null && message.CreatedAt < cutoff) .OrderBy(message => message.CreatedAt) .ToListAsync(cancellationToken); foreach (var message in stuck) { Console.WriteLine($"{message.Id} {message.Name}: {message.Attempts} attempts, {message.LastError}"); } ``` All times in the table are UTC. `CreatedAt` is when the row was added. `Timestamp` is the event's own time, which the code can set to any time, so it says nothing about how long a row has waited. `Data`, `User` and `Sessions` hold the event as JSON text. ## What to know - **The table holds the event as the SDK serialized it.** Middleware that changes events as they leave, such as encryption, runs when the dispatcher sends them, so the table holds the plain data. Middleware that wraps sends sees these events with no run, because no function sends them. - **Entity Framework Core 8 and 10.** Entity Framework Core 9 is not supported. `ExecuteUpdate` lives in a different assembly in Entity Framework Core 8 and in 9 and later, so the `net8.0` build, which an app on .NET 9 gets, only runs on Entity Framework Core 8. - **Providers.** The dispatcher uses only LINQ that Entity Framework Core translates for relational providers. The tests of this repository run it on SQLite with Entity Framework Core 8 and 10, and on PostgreSQL with Entity Framework Core 10 and Npgsql 10 on .NET 10 only. Other providers, and other versions of the PostgreSQL provider, were not tried. - **SQL Server.** With its default locking `READ COMMITTED`, a query waits for rows that open transactions have inserted. The dispatcher's query reads the part of the index that holds the events a transaction has just added, so one long unit of work that adds an event stalls the dispatchers of every instance until it commits, or until the command times out. Turn on `READ_COMMITTED_SNAPSHOT` for the database (`ALTER DATABASE ... SET READ_COMMITTED_SNAPSHOT ON`), which Azure SQL Database has by default. This was not run on SQL Server; it follows from how its locking works. PostgreSQL readers do not wait for writers, which the tests show with an event that a transaction has saved and not committed. - **One database per registration.** A dispatcher reads the database that `TContext` connects to in a new scope. An app that picks the database per tenant is not covered. - **No Native AOT.** Entity Framework Core does not support it, and the package does not declare itself compatible. The events are still serialized from the metadata of the serializer options, without reflection of its own. - **A host that stops loses nothing.** The events are in the table, and whichever instance runs next sends them. - **Tests.** After a save, a test can read the rows of `Set()` to see what was added. To run the dispatcher too, use SQLite in memory and register a fake `IInngestClient` before `AddInngest`, which keeps a client that is already registered. The package's own tests are built that way. The dispatcher logs under categories that start with `Inngest.EntityFrameworkCore`, with event ids 800 to 810. See [Sending events](https://damikun.github.io/inngest-dotnet/guides/sending-events.md) for events that are not part of a transaction. --- # The user behind an event in Inngest functions > Carry the signed-in user who caused a run into it with Inngest.UserContext, protected with ASP.NET Core Data Protection, and read it as ICurrentUser or ClaimsPrincipal in functions, steps, failure handlers and the events they send. Web page: https://damikun.github.io/inngest-dotnet/guides/user-context/ A run has no request behind it: no `HttpContext`, no cookie, no signed-in user. When a click starts a run, the function often still needs to know who clicked: to attribute the work, to call another service as that person, or to check what they may see. The `Inngest.UserContext` packages carry the user along. When your app sends an event, they attach selected claims of the current user to it, protected so that nobody can read or change them. In the run, middleware restores the claims as `ICurrentUser` and as a scoped `ClaimsPrincipal`. **The restored user is a snapshot from the time the event was sent, not a fresh authorization.** A run can start later and last for days. The user may have lost a role, been blocked or been deleted since. Use the restored user to know who caused the work, and check what that person may do against current data for anything that matters. ## Set up There are two packages: | Package | What it gives you | | - | - | | `Inngest.UserContext` | The format, the protector, the middleware, `ICurrentUser` and `AddUserContext`. It needs no ASP.NET Core, so a worker that only restores users, such as a [Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md) worker, uses this one alone. | | `Inngest.UserContext.AspNetCore` | `AddUserContextFromHttpContext`, which takes the user from `HttpContext.User`. For the app that sends events while it serves requests. | ```bash dotnet add package Inngest.UserContext dotnet add package Inngest.UserContext.AspNetCore ``` The user is protected with [ASP.NET Core Data Protection](https://learn.microsoft.com/aspnet/core/security/data-protection/introduction), which you set up yourself, because the apps that send and restore users have to [share its key ring](https://damikun.github.io/inngest-dotnet/guides/user-context.md#share-the-key-ring). The web app that sends events: ```csharp title="Program.cs" using Inngest; using Microsoft.AspNetCore.DataProtection; builder.Services .AddDataProtection() .SetApplicationName("shop") .PersistKeysToFileSystem(new DirectoryInfo("/var/shop/keys")); builder.Services .AddInngest(options => options.AppId = "shop-web") .AddUserContext(options => options.Claims = ["sub", "name", "email", "role"]) .AddUserContextFromHttpContext(); ``` `Claims` is the list of claim types that travel. Everything else in the principal stays behind. The worker that runs the functions needs the same key ring and application name, and only `AddUserContext`: ```csharp title="Program.cs of the worker" using Inngest; using Microsoft.AspNetCore.DataProtection; builder.Services .AddDataProtection() .SetApplicationName("shop") .PersistKeysToFileSystem(new DirectoryInfo("/var/shop/keys")); builder.Services .AddInngest(options => options.AppId = "shop-worker") .AddUserContext() .AddConnect(); ``` An app that sends events and serves them, as a single ASP.NET Core app often does, uses `AddUserContext` and `AddUserContextFromHttpContext` once. Register `AddUserContext` before middleware that needs the user, because middleware runs in registration order. ## Read the user in a function Take `ICurrentUser` where you need the user: ```csharp title="ExportReport.cs" using Inngest; public sealed record ReportRequested(string ReportId) : IInngestEventData { public static string EventName => "shop/report.requested"; } public interface IReportAccess { Task MayExportAsync(string userId, string reportId, CancellationToken cancellationToken); Task ExportAsync(string userId, string reportId, CancellationToken cancellationToken); } [InngestFunction("export-report")] public sealed class ExportReport(ICurrentUser user, IReportAccess access) : InngestFunction { public override async Task ExecuteAsync( FunctionContext context, CancellationToken cancellationToken) { if (!user.IsAuthenticated) { throw new NonRetriableException("A report is exported for the user who asked for it."); } var userId = user.Principal.FindFirst("sub")!.Value; var reportId = context.Event.Data.ReportId; // The snapshot says who asked. Whether they may still have the report is a question for today's data. var may = await context.Step.RunAsync("check-access", ct => access.MayExportAsync(userId, reportId, ct)); if (!may) { throw new NonRetriableException($"User {userId} may no longer export report {reportId}."); } return await context.Step.RunAsync("export", ct => access.ExportAsync(userId, reportId, ct)); } } ``` `ICurrentUser` has three members: | Member | Meaning | | - | - | | `Principal` | The restored `ClaimsPrincipal`: one identity with the authentication type `Inngest.UserContext` and the claims the sender allowed, as strings. The name and role claim types of the original identity are kept, so `Identity.Name` and `IsInRole` answer as they did in the request. Claim value types, issuers and properties are not carried. Without a user it is anonymous: no claims, and `Identity.IsAuthenticated` is false. | | `IsAuthenticated` | True when the event carried a user that is valid for this run. | | `IssuedAt` | When the sender took the claims, or null without a user. Use it to treat an old snapshot with more care. | A run has no user when the event came from somewhere that has none: a cron trigger, another service, a webhook, the Inngest dashboard, or a sender that is not signed in. Inngest Cloud documents that `event.user` is empty when it replays a function (see its [send reference](https://www.inngest.com/docs/reference/events/send)), so a replayed run has no user there. The open-source dev server reloads the user of the event instead, so development and production can differ. A replay or a rerun is a new run with an id of its own, and it is judged by the time it was created, so a payload that has expired by then is not accepted whichever server keeps it. `ICurrentUser` always answers for the run whose code is running. It is a singleton, and it is the same in every scope of the run: the scope of the call, a scope your code creates in a step, a singleton's. Outside a run it is nobody. The user is restored when the function starts, and the function class is created before that, so a service must read the user when it uses it and not in its constructor. For the same reason `OnRunStartAsync` and the other hooks that run before the function starts see nobody, and so does the code of a middleware registered before `AddUserContext` ahead of its `next()`. A task that the function starts keeps the user of its run for as long as the call lasts; once the call has answered, the task sees nobody. `ClaimsPrincipal` is also registered, as a scoped service, for code that wants one: it is the principal as it is when the service is created, which is anonymous before the function starts. An app that registered its own `ClaimsPrincipal` keeps it. ## Share the key ring The restoring app has to unprotect what the sending app protected. Data Protection makes that possible with three things, and its defaults give none of them to two separate deployments: 1. **The same keys.** Persist the key ring to a store both apps reach: a shared folder, Azure Blob Storage, Redis or Entity Framework Core (see [key storage providers](https://learn.microsoft.com/aspnet/core/security/data-protection/implementation/key-storage-providers)). The default is a folder in the user profile, or memory in a container, and then each app makes keys of its own. 2. **The same application name.** `SetApplicationName("shop")` in both apps. Without it Data Protection isolates apps by their content root path, and payloads do not cross. 3. **The same purpose,** which the package sets. Payloads of another feature that use the same keys, such as a cookie, are not accepted as users. The keys are secrets: whoever holds them can make a payload for any user. Encrypt them at rest with `ProtectKeysWith...` and limit who can read the store. A key lives 90 days by default. Data Protection creates the next one two days before, and an app reads the store at least daily, so apps have a new key before they use it (both are fixed in Data Protection). Expired keys still unprotect, so payloads made with them are read for as long as the key stays in the ring. A revoked key does not: revoking one with `IKeyManager.RevokeKey` is the way to stop trusting payloads made with a key that leaked, and running apps notice when they refresh their cached key ring. Set the ring up before the apps that share it start for the first time. Apps that both begin with an empty store each create a key, and may not read each other's payloads until their next refresh of the store. When the restoring app cannot unprotect a payload, the run has no user, and the log says why (see [when the user cannot be used](https://damikun.github.io/inngest-dotnet/guides/user-context.md#when-the-user-cannot-be-used)). ## What travels with the event The `user` field of an event is where Inngest documents user data, and it [stores it encrypted at rest](https://www.inngest.com/docs/reference/events/send). The packages add one key to it and leave all other keys to you: ```json "user": { "external_id": "u-42", "_inngest_dotnet_user": { "v": 1, "t": "CfDJ8..." } } ``` `v` is the format version. `t` is the protected text of this JSON, which only apps with the key ring can read: ```json { "v": 1, "iat": 1793268000, "exp": 1793872800, "ev": "shop/report.requested", "nt": "name", "rt": "role", "c": { "sub": ["u-42"], "name": ["Ada Lovelace"], "role": ["admin", "support"] } } ``` `iat` is when the claims were taken and `exp` when they stop being accepted for new runs. `ev` is the name of the event the text was made for: it is accepted on that event only, so a payload that leaked from one event cannot be attached to another to run as the user. An invocation is an event of one name for any function, and Inngest builds it from a body that its caller supplies, so the text of an invocation names the function too, in `fn`, as `-`. `nt` and `rt` are the name and role claim types when they are not the .NET defaults, and `c` holds the claims. Data Protection signs and encrypts the text, so what Inngest's dashboard, the logs of whatever reads events and every other function triggered by the event can see is ciphertext. Think of each claim you allow as personal data in an event. List what the runs need and nothing else: `Claims` is matched exactly against the claim types of the principal (the default allows `sub` and `ClaimTypes.NameIdentifier`), and whatever it does not list is never read. The JSON of the payload may be up to `MaxSize` bytes, 4096 by default. A user over the bound is not attached: the event is sent without it and a warning is logged. The protected text is about a third larger than the JSON, and an event can be 256 KiB on Inngest's Free plan and 3 MiB on Pro and Business (see its [usage limits](https://www.inngest.com/docs/usage-limits/inngest)). An event that should not carry the user, such as an audit event for another team, is left out with `EventFilter`. It runs for every event that is sent: ```csharp inngest.AddUserContext(options => { options.Claims = ["sub", "name"]; options.EventFilter = @event => !@event.Name.StartsWith("audit/", StringComparison.Ordinal); }); ``` ## How long a user is valid `Lifetime` is seven days by default. It is not how long a run may use the user. A function runs again from its top on every call, so a user that expired halfway through a run would change under it: a check at the top of the function would pass before a long sleep and fail after it. So the lifetime is judged once, by when the run was created and when its event asks it to start. A run id is a ULID, and its first part is the creation time; Inngest makes it, so the sender of an event cannot choose it. - A run created before the expiry, for an event that does not ask to start after it, keeps its user for as long as it lives. It can sleep, wait and retry for as long as Inngest lets a run last, and every call sees the same user. - A run created after the expiry has no user. This is what bounds a payload that leaked from an event and is sent again. - A run that Inngest created in time and then holds back, behind other runs in a queue say, keeps its user. An event that asks to start later does not. Inngest creates its run when it receives the event but starts it at the event's timestamp, which whoever sends the event chooses, so the run is judged by the later of the two times. An event scheduled further ahead than the lifetime carries no user. Time that passes before the run exists counts too, such as a debounce period. - A run id that is not a ULID, such as the made-up ids of a unit test, is judged by the clock at each call, which can change the answer while the run goes on. A warning says so once for the run. - The events and invocations a run sends carry the same expiry, not a new one. A chain of runs therefore cannot go on for longer than the lifetime of the first send: a run created after it has no user. Choose a lifetime that covers the longest time between sending an event and the time it asks its run to start, and for a chain of runs, between the first send and the last event of the chain. A longer lifetime lets a leaked payload start runs for longer. ## Pass the user on Events that a run sends carry the run's user, as they carry its [sessions](https://damikun.github.io/inngest-dotnet/guides/sending-events.md#sessions). This covers `context.Step.SendEventAsync` and an `IInngestClient` used inside the function. The source of the user is never asked there: the request that called the app belongs to Inngest, not to a user. `Propagate = false` turns it off, and `EventFilter` picks events. Each event gets a payload made for its own name. A user that will have expired by the time the event's run starts is not passed on: a run that sleeps past the expiry sends its events and invocations without a user, and writes an information line that names the event, not the user. Invocations do not pass through middleware, so the user goes to an invoked function when the code asks for it with `WithUser(context)`, with the function's `FunctionContext`. The invoked function restores it like any other: ```csharp var options = new InvokeOptions("render-report") { Data = context.Event.Data } .WithUser(context); var rendered = await context.Step.InvokeAsync("render", options); ``` Other keys that `InvokeOptions.User` already holds are kept. A run without a user passes none. A [failure handler](https://damikun.github.io/inngest-dotnet/guides/errors-and-retries.md) gets the user of the run that failed. Inngest sends it the failed run's event, with its `user`, and the user is judged as that run was. Other functions that listen for `inngest/function.failed` do not get it. ```csharp title="ImportCatalog.cs" using Inngest; public sealed record CatalogUploaded(string CatalogId) : IInngestEventData { public static string EventName => "shop/catalog.uploaded"; } public interface IAlerts { Task TellAsync(string userId, string message, CancellationToken cancellationToken); } [InngestFunction("import-catalog")] public sealed class ImportCatalog(ICurrentUser user, IAlerts alerts) : InngestFunction, IFailureHandler { public override Task ExecuteAsync( FunctionContext context, CancellationToken cancellationToken) => throw new NonRetriableException("The catalog is not valid."); public async Task OnFailureAsync(FunctionContext context, CancellationToken cancellationToken) { if (user.IsAuthenticated) { await alerts.TellAsync(user.Principal.FindFirst("sub")!.Value, "Your catalog could not be imported.", cancellationToken); } } } ``` There is no single user where there is no single event. A function that batches events has none, and neither has the failure handler of one. The events that `WaitForEventAsync` matched are results of steps, and their `user` is not restored. ## When the user cannot be used An event without the reserved key is not an error: the run has no user. A payload that is there and cannot be used is, and `OnInvalid` decides what the run does: | Setting | A run whose payload cannot be used | | - | - | | `Anonymous` (the default) | Writes one warning and runs without a user. | | `FailRun` | Fails. An expired payload, one made for another event or function and one with an unknown format fail without retries; one that cannot be unprotected is retried, because the app may not have the sender's newest key yet, and fails on the last attempt. A failure handler never fails on its user: it takes the `Anonymous` path, so that it can do what it is there for. | | Reason in the log | What it means | | - | - | | it has expired | The run was created after the expiry. | | it was made for another event or function | The event name, or for an invocation the invoked function, is not the one the payload was made for. | | it could not be unprotected | It was changed, or made with a key or purpose this app does not have. The app's key ring or application name usually differs from the sender's. A key store that cannot be read is not this: Data Protection reports it as an exception, the call fails whatever `OnInvalid` says, and Inngest retries it, because running without the user would give the same run different users on different calls. | | its format is not known to this version | The format version is newer than this package. Deploy the restoring apps before the senders when you upgrade. | | it is not valid | The payload was protected but is not a user. | The warning is written once for the run and names the event and the reason. No log line, exception message or trace of these packages contains a claim or the protected text, and yours should not either. ## What someone who can send events can do Anyone with your event key can send an event with any `user`. What they can do with it: - **Make up a user:** nothing. Text that the key ring did not produce does not unprotect, the run has no user, and a warning is logged. They cannot choose claims. - **Move a payload to another event:** nothing. A payload is bound to the name of its event, and one made for an invocation to the function that is invoked. - **Send a payload they copied again,** for example from the dashboard: the run starts as that user until the payload expires, with any data they like. A far timestamp does not stretch it, because a run is judged by the later of its creation and the event's timestamp. Keep `Lifetime` as short as your delays allow, and keep the event key secret. - **Read the claims:** nothing, without the keys. What the payload protects is the claims, the times, the name of the event and, for an invocation, the function. Nothing else in the event is part of it: not the data, the id, the timestamp or the other keys of `user`, which anyone who can send the event can set. So a restored user says who the sender was and not that the rest of the event is theirs: a function that acts for a user checks the data against the user, such as an order's owner against `sub`, and makes the work idempotent. Whoever holds the key ring can make payloads for any user, so treat it like a signing key. ## Other hosts and other protectors `AddUserContextFromHttpContext` is a source of the user for sends outside a run. For another kind of host, register an `IUserContextSource`. This one takes the user of a Blazor circuit: ```csharp title="AuthenticationStateSource.cs" using System.Security.Claims; using Inngest; using Microsoft.AspNetCore.Components.Authorization; public sealed class AuthenticationStateSource(AuthenticationStateProvider provider) : IUserContextSource { public async ValueTask GetUserAsync(CancellationToken cancellationToken) => (await provider.GetAuthenticationStateAsync()).User; } ``` ```csharp inngest.AddUserContext(); inngest.Services.AddSingleton(); ``` It is asked for every send outside a run, and only an authenticated user is attached. To protect users some other way, such as with a key from a vault, register an `IUserContextProtector` as a singleton. It turns the JSON of the claims into text and back, and the packages do the rest: the format, the expiry, the binding to the event. It has to detect changes and should encrypt, because the text is visible to anyone who can see the event. Its `TryUnprotect` returns false for text that is not valid and throws for failures that are not the text's fault, so that Inngest retries the call. When one is registered, Data Protection is not used and need not be set up. ## Testing Functions that take `ICurrentUser` are tested with a fake in the services: ```csharp title="SignedInAs.cs" using System.Security.Claims; using Inngest; public sealed class SignedInAs(string userId) : ICurrentUser { public ClaimsPrincipal Principal { get; } = new(new ClaimsIdentity([new Claim("sub", userId)], "Test")); public bool IsAuthenticated => true; public DateTimeOffset? IssuedAt => null; } ``` Register it with `services.AddSingleton(new SignedInAs("u-42"))` in the services of the [test host](https://damikun.github.io/inngest-dotnet/guides/testing.md). `WithUser(context)` takes the user from the run and not from `ICurrentUser`, so a fake alone passes none to an invocation, and without `AddUserContext` it throws. To test how the user travels, register `AddUserContext` with Data Protection in the test services and give the event a user: the host restores it, and records the events a function sends, so `SentEvents` shows the payload they carry. ## Compared with other libraries MassTransit and NServiceBus carry the user in a message header that a filter or behavior sets when a message is sent and reads when it is consumed, and Hangfire jobs take the user id as an argument or a job parameter, because a job has no request. These are plain values that the broker is trusted to keep. Inngest accepts an event from anyone who has the event key, and the user is stored in the event, so these packages protect it and bind it to the event, and judge it by the time the run was created. The TypeScript SDK has no equivalent, and other SDKs see an opaque `_inngest_dotnet_user` key in `user`. --- # Webhooks > Receive webhooks from Stripe, GitHub, Shopify, Clerk, Resend and other providers in ASP.NET Core, verify their signatures before anything else, and send each as an Inngest event whose id is the delivery id, so that a redelivery starts no second run. Web page: https://damikun.github.io/inngest-dotnet/guides/webhooks/ Services tell your app what happened by sending it an HTTP request: a payment succeeded, a pull request was opened, a user signed up. `MapInngestWebhook` turns those requests into Inngest events. It reads the raw body, verifies the sender's signature before it parses anything, makes an event whose id is the provider's id for the delivery, sends it and answers at once. A function does the work, with all the retries, steps and flow control of any other. The signature schemes are written out with `System.Security.Cryptography` and need no provider SDK: Stripe, GitHub, Shopify, and any provider that follows [Standard Webhooks](https://www.standardwebhooks.com), such as Svix, Clerk and Resend. Other providers that sign with an HMAC are a few lines of options, and anything else is an interface to implement. ## Receive webhooks Map one endpoint for each provider, with the secret the provider gave you for it: ```csharp app.MapInngestWebhook("/webhooks/stripe", WebhookProvider.Stripe(configuration["Stripe:WebhookSecret"]!)); app.MapInngestWebhook("/webhooks/github", WebhookProvider.GitHub(configuration["GitHub:WebhookSecret"]!)); app.MapInngestWebhook("/webhooks/shopify", WebhookProvider.Shopify(configuration["Shopify:ClientSecret"]!)); app.MapInngestWebhook("/webhooks/clerk", WebhookProvider.StandardWebhooks("clerk", configuration["Clerk:WebhookSecret"]!)); app.MapInngestWebhook("/webhooks/resend", WebhookProvider.StandardWebhooks("resend", configuration["Resend:WebhookSecret"]!)); ``` Give the provider the URL of the endpoint, over HTTPS. `AddInngest` must have been called, because the endpoint sends through the app's `IInngestClient`. The endpoint allows anonymous callers, since a webhook proves who sent it with its signature rather than with a login, so an app-wide authorization policy does not turn providers away. A function listens for the events by name, as for any event. This one starts when Stripe says a checkout was paid: ```csharp using System.Text.Json; public sealed record StripeEvent(string Id, string Type, StripeEventData Data); public sealed record StripeEventData(JsonElement Object); ``` ```csharp using Inngest; [InngestFunction("fulfil-order")] [EventTrigger("stripe/checkout.session.completed")] public sealed class FulfilOrder : InngestFunction { public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) { var session = context.Event.Data.Data.Object; var sessionId = session.GetProperty("id").GetString()!; await context.Step.RunAsync("fulfil", () => Task.CompletedTask); return sessionId; } } ``` Providers wait only seconds for an answer, so the endpoint does no more than receive. Whatever takes time belongs in the function. ## What happens to a request 1. The body is read as raw bytes, up to `WebhookOptions.MaxBodySize` (1 MiB by default). A larger one is answered with 413 without being read to its end. 2. The signature is verified against the raw body, and the signed timestamp, if the provider signs one, against the clock. A request that fails is answered with 401, and nothing else about it is looked at. 3. The body is parsed as JSON and made into an event by the provider's conventions, [below](https://damikun.github.io/inngest-dotnet/guides/webhooks.md#the-event). A body that is not JSON, or that no event can be made of, is answered with 400. 4. Your `Transform`, if you set one, changes the event or drops the delivery. 5. The event is sent to Inngest, and the request is answered once Inngest has accepted it. Nothing waits for a run. | Answer | When | | - | - | | 200 | The event was sent. | | 204 | `Transform` returned null for the delivery. | | 400 | The signature is valid, but the body is not JSON, or no event can be made of it (a delivery id, an event name or a type is missing or malformed), or the event can never be sent: Inngest answers it with a client error, the client refuses it (a name with the `inngest/` prefix, which Inngest reserves, or data that is not an object), or its payload cannot be written as JSON (a string with an unpaired surrogate such as `"\ud83d"` parses but cannot be written). | | 401 | The signature, or a header it needs, is missing, malformed or wrong, or the signed timestamp is outside the tolerance. | | 413 | The body is larger than `MaxBodySize`, or Inngest answers that the event is too large. | | 503 | Inngest did not accept the event within `SendTimeout`, four seconds by default, or failed in a way that may pass: no connection, a 5xx, or 408, 425 or 429. | | 500 | The verifier or the `Transform` threw. | No answer has a body, so a caller who cannot prove who it is learns nothing from them. Anything but a 2xx is a failed delivery to the provider. Most providers send the webhook again on their own schedule when they get one; GitHub does not. Because the event id is the provider's id for the delivery, a delivery that reached Inngest after all is dropped the second time. The log says why a request was rejected, in words of the SDK's own and never with a secret, a signature or a body. A failure that cannot pass is not left to look like one that can. An event Inngest or its client will never accept is answered with a client error and logged as an error that says sending the webhook again fails the same way, so that neither the provider nor you wait for a retry that cannot work. Only a failure that may pass, an unreachable Inngest or a 5xx, is a 503. > **Shopify deletes a subscription whose deliveries keep failing** > > Shopify retries a failed delivery eight times over four hours, counts any answer outside the 200 range as a failure, and > deletes a subscription that was created through the Admin API after eight failures in a row. A 400 or a 401 counts like a > 503\. Topics that you do not handle should be dropped with a `Transform` that returns null, which answers 204, instead of > being left to fail. ## The event | Provider | Factory | Name | Data | Id | | - | - | - | - | - | | Stripe | `WebhookProvider.Stripe(secret)` | `stripe/` and the event's `type`, such as `stripe/payment_intent.succeeded` | The whole Stripe event | The event's `id`, `evt_...` | | GitHub | `WebhookProvider.GitHub(secret)` | `github/` and `X-GitHub-Event`, such as `github/pull_request` | The payload | `X-GitHub-Delivery` | | Shopify | `WebhookProvider.Shopify(secret)` | `shopify/` and `X-Shopify-Topic` with the slash as a dot, such as `shopify/orders.create` | The payload | `X-Shopify-Webhook-Id` | | Standard Webhooks, Svix, Clerk, Resend | `WebhookProvider.StandardWebhooks(prefix, secret)` | The prefix you choose and the payload's `type`, such as `clerk/user.created` | The payload's `data`, or the payload when it has none | `webhook-id`, or `svix-id` | | Other HMAC | `WebhookProvider.Hmac(name, options, secret)` | The name you give, for every event | The payload | The header you name, if any | These follow the transforms in the [Inngest guide to webhooks](https://www.inngest.com/docs/platform/webhooks), where it shows one: `stripe/` with the event as the data and its id as the id, and `clerk/` and `resend/` with the `data` of the payload. Its GitHub example names events `github.` with a dot, where this uses a slash like the others. Rename them with a `Transform` if functions already trigger on the dot. The data is the payload exactly as the provider sent it, so a record for it has the provider's own property names, and it is only ever what the signature covers: nothing from a header is added to it. The payload must be a JSON object, as the data of every Inngest event is. A provider that has delivery ids must have sent one: a GitHub or Shopify delivery without its id header, or a Stripe event without an `id`, is answered with 400, because an event without an id would never be deduplicated. A name or an id taken from a request is at most 256 characters of visible ASCII, and a whole event name is at most 256, so a header that is not a short token never becomes the name or the id of an event. Where the name and the id come from matters for what can be trusted. Stripe and Standard Webhooks sign them: the type and the event id are in the signed body, and `webhook-id` is part of the signed text. GitHub and Shopify sign only the body, so the event name and the id of a delivery travel in headers that nothing signs, and so does the shop domain that Shopify sends. The name and the id are checked for their shape and used, but whoever holds one captured delivery can change them, as [Replays](https://damikun.github.io/inngest-dotnet/guides/webhooks.md#replays) says. ## Changing the event `Transform` receives the delivery and the event the provider's conventions made, and returns the event to send. Return null to drop a delivery, which is answered with 204. This ignores the Stripe events nothing listens to, and moves the rest under another name: ```csharp app.MapInngestWebhook("/webhooks/stripe", WebhookProvider.Stripe(secret), new WebhookOptions { Transform = (delivery, @event) => { if (!@event.Name.StartsWith("stripe/invoice.", StringComparison.Ordinal)) { return null; } return @event with { Name = "billing/" + @event.Name["stripe/".Length..] }; }, }); ``` The delivery has its parsed `Payload` as a `JsonElement`, its `DeliveryId`, any request header through `GetHeader`, and the request's `Services`. The `Transform` runs on the request, after the signature has been verified, so keep it quick and let a function do the work. If it throws, the request is answered with 500 and a provider that retries tries again. Headers are not signed by most providers, so data taken from one is not proof of anything. The delivery, and its `Payload`, are valid only while the request is handled, because the payload is read in place from the body, which goes back to a pool afterwards. The event is not tied to them: a `JsonElement` as the `Data` or the `User` of the event that comes out of the `Transform` is copied before the event is sent, so an event that something keeps, such as `InngestTestHost.SentEvents` or a middleware, can be read after the request. A `JsonElement` of the payload inside another object is not copied; call `Payload.Clone()` when you put one there. ### Adding a header to the data The data of a Shopify event is the payload, which does not say which shop sent it. An app installed on many shops can add the header in a `Transform`. That header is not signed, so it is a claim of whoever sent the request, and whoever holds a captured delivery can send it again with another shop in it. Prefer a shop that the payload itself names where there is one. ```csharp using System.Text.Json.Nodes; app.MapInngestWebhook("/webhooks/shopify", WebhookProvider.Shopify(secret), new WebhookOptions { Transform = (delivery, @event) => { // A copy of the payload as an object, with one property more. var data = JsonNode.Parse(delivery.Payload.GetRawText())!.AsObject(); data["shop_domain"] = delivery.GetHeader("X-Shopify-Shop-Domain"); return @event with { Data = data }; }, }); ``` A trimmed or Native AOT app adds `JsonObject` to its `JsonSerializerContext` with `[JsonSerializable(typeof(JsonObject))]`, as for any type it sends. ## Redeliveries and duplicates Providers send a webhook again when they get no 2xx, and some send the same one twice for no reason. Each delivery carries an id that is the same for every attempt, and the event takes it as its id. Inngest keeps the first event with an id and starts no runs for the later ones, for 24 hours; the later ones show in the event history. So a webhook that reached Inngest once and is delivered again starts one run, and one that was answered with a 5xx starts its run on the attempt that gets through. What this does not cover: - **A delivery without its id.** For a provider that has delivery ids, a delivery that comes without its id is answered with 400 and is not sent: an event without an id would never be deduplicated. - **Different ids for one thing.** Stripe says that in some cases two separate events are sent for what happened once. Their ids differ, so both start runs. Its advice is to identify them by the id of the object in `data.object` together with the event type, which is what a function's `Idempotency` key can be made of. - **The same id for several events.** GitHub's API describes the id in `X-GitHub-Delivery` as the identifier of the event, shared by the deliveries of all the webhooks that subscribe to it. A redelivery has it, and so do two webhooks of yours that GitHub sends the same event to. Shopify's `X-Shopify-Webhook-Id` is different for each subscription, and its `X-Shopify-Event-Id` is the one they share. If two endpoints send to the same Inngest environment and must both start runs, make the ids differ in a `Transform`, such as `@event with { Id = delivery.GetHeader("X-GitHub-Hook-ID") + ":" + @event.Id }`. - **A manual redelivery.** GitHub does not retry on its own. Redelivering from its page or its API, or resending an event from the Stripe dashboard or CLI, sends the same id again, which Inngest drops for 24 hours if the first attempt got through. To run the work again, rerun the function from the Inngest dashboard. - **Features that ignore event ids.** Inngest documents that event idempotency does not apply to debouncing, to event batching and to a paused function. A function that uses them needs an idempotency key of its own. - **Ids after 24 hours.** The same id is a new event after that. ## Replays The signature proves who sent a request. It does not prove the request is new, so providers that sign a timestamp ask you to reject old ones, and this does. What it also does not prove is what a header says, when the signature does not cover the header: | Provider | Signed | Not signed, and used | Someone who holds one captured delivery can | | - | - | - | - | | Standard Webhooks | The id, the timestamp and the body, so also the `type` that names the event | Nothing | Send it again for five minutes, in either direction from the signed time, under the same name and id, which Inngest drops as a duplicate | | Stripe | The timestamp and the body, so also the event's `type` and `id` | Nothing | The same, for the same five minutes | | GitHub | The body | `X-GitHub-Event`, the event name, and `X-GitHub-Delivery`, the event id | Send it again at any time, under any event name and any id: a push body as `github/release`, and as often as they like, each time as a new event | | Shopify | The body | `X-Shopify-Topic`, the event name, `X-Shopify-Webhook-Id`, the event id, and `X-Shopify-Shop-Domain` if you add it to the data; `X-Shopify-Triggered-At` is ignored | Send it again at any time, under any topic, any id and any shop | | Other HMAC | The body, and the timestamp if you name its header | The header you name as the delivery id | Send it again under any id, and without a timestamp header at any time | For GitHub and Shopify the only defence inside the SDK is Inngest dropping a second event with the same id, and that reaches only so far: it holds for the same id, for 24 hours. A captured request sent again with another id is a new event, and so is any replay after the 24 hours. The delivery id header is required, so a request cannot leave it out to skip that, but it can name another. What helps is outside the webhook: keep the secret secret and the endpoint on HTTPS, allow only the addresses the provider sends from (Stripe and GitHub publish them) at your proxy, narrow what a replay can do with a `Transform` that returns null for every event name you do not handle, and give a function an idempotency key made of what the signed body contains, such as the id of an object in it, so that a replay under a new event id still does the work once. The tolerance can be changed per provider, for a server whose clock you cannot keep right, but not switched off. Keep the server's clock in sync: a wrong clock rejects real webhooks. ## Secrets and rotation Every factory takes one secret or a list. A request is accepted when any signature in it matches any of the secrets, so a secret can be replaced without downtime: put the new one in beside the old one, switch the provider, and remove the old one later. ```csharp app.MapInngestWebhook("/webhooks/stripe", WebhookProvider.Stripe(["whsec_new...", "whsec_old..."])); ``` When you roll a secret in Stripe and keep the old one, Stripe signs with both for up to 24 hours, and a Standard Webhooks sender signs with the old key and the new one for a while; the verifier checks every signature in the header. Shopify says a signature can take up to an hour to follow a change of the client secret. GitHub signs with the one secret of the webhook. The secrets are used exactly as written, which is how each provider signs: Stripe's and GitHub's as text, Standard Webhooks' as the base64 after `whsec_`. A newline at the end of a secret that was read from a file makes every signature fail. Keep them in configuration or a secret store, not in source. ## Other providers A provider that signs the body with an HMAC and sends the signature in a header needs only options. The signed text is the optional `SignedContentPrefix`, then the optional timestamp from `TimestampHeader` and `TimestampSeparator`, then the body. Slack, for one, signs `v0:{timestamp}:{body}` and sends `v0=` and the hex digest in `X-Slack-Signature`, with a timestamp that has to be within five minutes: ```csharp var options = new HmacWebhookOptions { SignatureHeader = "X-Slack-Signature", SignaturePrefix = "v0=", TimestampHeader = "X-Slack-Request-Timestamp", SignedContentPrefix = "v0:", TimestampSeparator = ":", }; app.MapInngestWebhook("/webhooks/slack", WebhookProvider.Hmac("slack/event", options, secret)); ``` These options are Slack's signing and not a complete Slack integration. Slack's Events API also checks a new URL with a `url_verification` request, which has to be answered with the challenge in the body, and the answers of this endpoint have no body. Handle that request in an endpoint of your own. `Algorithm` is SHA-256 unless you set SHA-1, SHA-384 or SHA-512, `SignatureEncoding` is hexadecimal unless you set base64, and `DeliveryIdHeader` names the header whose value becomes the event id. Every event has the one name you gave; use a `Transform` to name events after their content. The body must be JSON. Without a `DeliveryIdHeader` the events have no id, and nothing deduplicates them. With one, a delivery that lacks the header is answered with 400, and the signed text does not include the header, so whoever holds one captured request can send it again under another id. For anything else, implement `IWebhookVerifier`. It sees the raw body and the headers, and has to give an answer for whatever it is handed, because it runs before anyone has proved who they are: return `Invalid` instead of throwing, and compare secrets with `CryptographicOperations.FixedTimeEquals`. The reason you give goes to the log, so it must not hold anything from the request. ```csharp using System.Security.Cryptography; using System.Text; using Inngest; public sealed class SharedTokenVerifier(string token) : IWebhookVerifier { private readonly byte[] _expected = SHA256.HashData(Encoding.UTF8.GetBytes(token)); public WebhookVerification Verify(IWebhookRequest request, DateTimeOffset now) { if (request.GetHeader("X-Acme-Token") is not { Length: > 0 } sent) { return WebhookVerification.Invalid("the token header is missing"); } // Digests have the same length, so the comparison does not tell how much of the token was right. var matches = CryptographicOperations.FixedTimeEquals(SHA256.HashData(Encoding.UTF8.GetBytes(sent)), _expected); return matches ? WebhookVerification.Valid(request.GetHeader("X-Acme-Delivery")) : WebhookVerification.Invalid("the token does not match"); } } ``` A `WebhookProvider` joins the verifier with the code that makes the event of a verified delivery, which returns null for one that cannot be an event: ```csharp app.MapInngestWebhook("/webhooks/acme", new WebhookProvider( "acme", new SharedTokenVerifier(token), delivery => delivery.Payload.ValueKind == JsonValueKind.Object && delivery.Payload.TryGetProperty("kind", out var kind) && kind.ValueKind == JsonValueKind.String ? new InngestEvent("acme/" + kind.GetString(), delivery.Payload) { Id = delivery.DeliveryId } : null)); ``` ## Limits and settings | Setting | Default | Notes | | - | - | - | | `MaxBodySize` | 1 MiB | The body is held in memory and read before anything else, so this bounds what an anonymous caller can make the app hold. Inngest refuses events over 256 KiB on its free plan and 3 MiB on Pro and Business, so a larger limit only turns a 413 here into a failed send. | | `SendTimeout` | 4 seconds | How long to wait for Inngest before answering 503. The client retries a failed send with backoff, which can take longer than a provider waits: Shopify allows five seconds in all. | Both are in `WebhookOptions`, an immutable record like the SDK's other options. The endpoint returns an `IEndpointConventionBuilder`, so rate limiting, host filtering and the rest of ASP.NET Core's endpoint features apply: ```csharp app.MapInngestWebhook("/webhooks/stripe", WebhookProvider.Stripe(secret), new WebhookOptions { MaxBodySize = 256 * 1024 }) .RequireHost("webhooks.example.com"); ``` The request goes through the rest of the pipeline first. Middleware that reads or changes the body makes every signature fail, and a redirect from HTTP to HTTPS is answered with a 3xx that providers count as a failure, so register the HTTPS URL with them. GitHub must be set to send `application/json`; its form-encoded format is not JSON. ## Hosted webhooks or `MapInngestWebhook` Inngest can also receive webhooks itself: you give the provider a URL at Inngest, and a JavaScript transform you write turns each request into an event. Neither is better everywhere: | | Inngest webhooks | `MapInngestWebhook` | | - | - | - | | Where requests go | To Inngest, which accepts them even while your app is down | To your app, which has to be up; providers retry for a while (Stripe for up to three days in live mode, Shopify eight times over four hours, GitHub not at all) | | Signatures | Not verified by Inngest. The transform is handed the raw body and the headers, and the function has to check them after the event exists | Verified before an event exists, so a forged request never reaches Inngest | | Mapping | JavaScript, kept in Inngest | C# in your app, with its types and services, under its tests | | Event id | Whatever the transform sets | The provider's delivery id | | Operating | Nothing to host | An endpoint on the internet to protect and keep up | Hosted webhooks fit when you do not want an endpoint to run, or when a provider's retries are too short for the downtime you can have. `MapInngestWebhook` fits when forged requests must never become events, when the mapping is more than a few lines, or when you want your own authorization, rate limiting and tests in front of it. They mix: use whichever fits each provider. ## Other hosts, testing and troubleshooting `MapInngestWebhook` is in `Inngest.AspNetCore`, which brings in `Inngest.Webhooks`: the verifiers, the providers and the event conventions. That package depends on `Inngest` and on nothing of ASP.NET Core, and the verifiers read the raw bytes and the headers through `IWebhookRequest`, so a host that is not ASP.NET Core, such as an Azure Function or a Lambda that receives the request itself, can verify with them. The host owns the body: the verifiers and the parser read it in place, so it must stay unchanged until the request has been handled, and may be reused or cleared after. A verifier needs no running endpoint to test, and `Verify` takes the time it should take for "now", so a tolerance can be tested without waiting. A `Transform` can be tried without a server too: `new WebhookDelivery(provider, deliveryId, payload, request)` makes the delivery it takes, with any `IWebhookRequest` for the headers, or none. To test an endpoint as a whole, give it the `IInngestClient` of an `InngestTestHost` and read `SentEvents` after the request; each event holds its own copy of the payload, so it can be read then. To try a real provider, `stripe listen --forward-to localhost:5000/webhooks/stripe` forwards test events to your machine and prints the signing secret to use there. When a webhook is rejected, the log says why. `no signature matches the body for any secret` means a wrong secret, a secret of the other mode (Stripe's test and live endpoints have different ones), a newline at the end of the secret, or a body that something changed on the way. `the timestamp is outside the tolerance` means a replay or a clock that is wrong. Related: [Sending events](https://damikun.github.io/inngest-dotnet/guides/sending-events.md#deduplicating-with-an-id) explains event ids, and the [webhook pipeline](https://damikun.github.io/inngest-dotnet/examples/webhook-pipeline.md) example handles a provider by hand and processes each delivery once. --- # Realtime > Publish updates and streams of text from a run, receive them in .NET, and relay them to browsers, through Inngest Realtime. Web page: https://damikun.github.io/inngest-dotnet/guides/realtime/ A durable function can run for minutes, retry and go on after the request that started it has ended. Realtime lets it report back as it goes: it publishes messages to a channel, and the subscribers of that channel receive them. The SDK publishes data and streams of text, receives messages in .NET, mints the tokens the TypeScript SDK's `useRealtime` subscribes with, and relays messages to browsers as server-sent events or through SignalR. Everything goes through Inngest Realtime, which delivers a message to the subscribers connected when it is published and keeps none for later. ## Channels and topics A channel is a stream for one thing users watch, such as `order:o-1001`. A topic is one kind of message in it, such as `status` or `tokens`, and subscribers choose the topics they receive. `RealtimeTopic` names both. `RealtimeTopic` also fixes the type of the data, so the compiler refuses data of another type and subscribers read it as a `T`: ```csharp public sealed record OrderStatus(string Text, int Percent); ``` ```csharp var status = new RealtimeTopic("order:o-1001", "status"); var tokens = new RealtimeTopic("order:o-1001", "tokens"); ``` ## Publishing from a function The step tool publishes once: a retry or a replay returns the saved data without publishing again. ```csharp var status = new RealtimeTopic($"order:{context.Event.Data.OrderId}", "status"); await context.Step.PublishAsync("publish-received", status, new OrderStatus("Received", 10)); await context.Step.RunAsync("charge", () => Task.CompletedTask); await context.Step.PublishAsync("publish-charged", status, new OrderStatus("Charged", 50)); ``` `PublishStreamAsync` streams text as it arrives, such as a model's reply, in one request. Subscribers receive it piece by piece, and the step returns all of it. A replay returns the saved text without reading the chunks again, so the model is not called twice: ```csharp var tokens = new RealtimeTopic($"order:{context.Event.Data.OrderId}", "tokens"); var summary = await context.Step.PublishStreamAsync( "write-summary", tokens, chat.StreamAsync($"Summarize order {context.Event.Data.OrderId} for the customer.", cancellationToken)); ``` With `IChatClient` of `Microsoft.Extensions.AI`, pass the `Text` of each update of `GetStreamingResponseAsync`, or let [the chat client of `Inngest.Extensions.AI`](https://damikun.github.io/inngest-dotnet/guides/ai.md#streaming) publish it while it makes the model call a step. ## Publishing from anywhere `IInngestRealtime`, which `AddInngest` registers, publishes at once from anywhere, such as a controller or a background service. Inside a run it tags the message with the run. Unlike the step tools, it publishes again when the code around it is retried. ```csharp var status = new RealtimeTopic("order:o-1001", "status"); await realtime.PublishAsync(status, new OrderStatus("Shipped", 100), cancellationToken); ``` Text is published as it is and anything else as JSON with the app's serializer options, as the TypeScript SDK does. `PublishStreamAsync` streams from anywhere too. ## Subscribing in .NET `SubscribeAsync` receives the messages of a topic from now on, until its token is cancelled, so a worker, a Blazor Server component or a test can follow a run: ```csharp var status = new RealtimeTopic("order:o-1001", "status"); await foreach (var message in realtime.SubscribeAsync(status, cancellationToken)) { Console.WriteLine($"{message.Data?.Text} ({message.Data?.Percent}%)"); } ``` `SubscribeAsync(channel, topics)` receives several topics of a channel, with the data as `JsonElement`. Each message has a `Kind`: `Data` for published data, and for a stream a `StreamStart`, then `StreamChunk` messages with the text in `Text`, and a `StreamEnd`, all with the same `StreamId`. The subscriber connects with a token it mints itself, so in cloud mode it needs the signing key, as publishing does. It connects again with a new token when the connection drops and when Inngest closes it, which it does every 15 minutes; what is published meanwhile is missed. A message whose data is not the topic's type is skipped, with a warning in the log. ## In the browser ### With server-sent events `MapInngestRealtime` serves the messages of a channel as server-sent events, which a page reads with `EventSource` and no Inngest client. The callback decides what a request may receive, from its route values and its user, or returns null to refuse it with 403: ```csharp app.MapInngestRealtime("/api/orders/{orderId}/updates", context => { var orderId = (string)context.Request.RouteValues["orderId"]!; // Check here that the signed-in user may see the order. return ValueTask.FromResult(new($"order:{orderId}", ["status", "tokens"])); }) .RequireAuthorization(); ``` Each event's data is a message as Inngest sends it: ```js const updates = new EventSource(`/api/orders/${orderId}/updates`); updates.onmessage = (event) => { const message = JSON.parse(event.data); if (message.kind === "data") showStatus(message.topic, message.data); if (message.kind === "chunk") appendText(message.data); }; ``` A message is `{ kind, channel, topic, data, run_id, created_at }`; the chunks of a stream are `{ kind: "chunk", stream_id, data }` between a `datastream-start` and a `datastream-end` whose `data` is the stream id. Requests for the same topics of a channel share one subscription to Inngest, so a thousand pages watching one order cost one connection to it. The browser holds no token, the app's own sign-in guards the endpoint, and an `EventSource` reconnects by itself. Every connection gets a comment every 15 seconds, so proxies keep it open, and a page that falls 512 messages behind is disconnected rather than holding the others up. ### With Blazor The `RealtimeView` component of `Inngest.Blazor` shows the messages of a topic in a Blazor page. On the server it subscribes with the app's signing key, as `SubscribeAsync` does; in WebAssembly it reads the endpoint above. See [Realtime in Blazor](https://damikun.github.io/inngest-dotnet/guides/blazor.md). ### With SignalR An app that has SignalR can bring the messages to its clients through a hub, where a client joins and leaves channels with method calls and the app's callback decides who may join what. Clients that join the same topics share one subscription to Inngest, and several instances on Redis or Azure SignalR Service each send only to their own clients. See [SignalR](https://damikun.github.io/inngest-dotnet/guides/signalr.md). ### With the TypeScript client The TypeScript SDK's `useRealtime` hook and `subscribe` connect to Inngest themselves, with a token your server mints for one channel and the topics the browser may read, after checking that the signed-in user may see them: ```csharp app.MapGet("/api/orders/{orderId}/realtime-token", async (string orderId, IInngestRealtime realtime, CancellationToken cancellationToken) => { // Check here that the signed-in user may see the order. var token = await realtime.GetSubscriptionTokenAsync($"order:{orderId}", ["status", "tokens"], cancellationToken); return Results.Ok(token); }); ``` The JSON of a `RealtimeToken` is the token object of the TypeScript SDK: `channel`, `topics`, `key`, a short-lived JWT, and `apiBaseUrl`, where the browser connects. A React component hands the endpoint to the hook: ```tsx import { useRealtime } from "inngest/react"; const realtime = useRealtime({ channel: `order:${orderId}`, topics: ["status", "tokens"], token: () => fetch(`/api/orders/${orderId}/realtime-token`).then((response) => response.json()), }); ``` See [React hooks](https://www.inngest.com/docs/realtime/guides/react-hooks) in the Inngest docs for what the hook returns. ## Limits These are Inngest's: | What | Limit | | - | - | | A published message | 512 KB | | A stream | 1,000 chunks of up to 1 KB each | | A subscription token | Connects within a minute of being minted | | Delivery | To the subscribers connected when the message is published; nothing is kept for later | ## Testing The [test host](https://damikun.github.io/inngest-dotnet/guides/testing.md) records published messages instead of sending them: `run.Published` holds those of one run and `host.Published` all of them, each with its topic and its data as JSON. A stream is recorded as one message with all its text and `IsStream` set. Tokens the host mints are placeholders, and subscribing fails, as nothing is published where a subscriber could read it. --- # Realtime in Blazor > Show Inngest realtime messages in Blazor Server, WebAssembly and interactive Auto with the RealtimeView component of Inngest.Blazor, or read them as an async stream from IRealtimeSource. Web page: https://damikun.github.io/inngest-dotnet/guides/blazor/ [Realtime](https://damikun.github.io/inngest-dotnet/guides/realtime.md) lets a run report back to the people watching it. `Inngest.Blazor` shows those messages in a Blazor page: the `RealtimeView` component subscribes to a topic, keeps what arrives and renders your markup again each time something does. The same component runs in Blazor Server, in WebAssembly and in interactive Auto, where a page starts on the server and moves to WebAssembly. Where the messages come from depends on where the component runs, and the component does not know: - **On the server** it reads Inngest itself, with the app's signing key, through `IInngestRealtime`. - **In WebAssembly**, which holds no key and should not, it reads the server-sent events of a [`MapInngestRealtime`](https://damikun.github.io/inngest-dotnet/guides/realtime.md#with-server-sent-events) endpoint of the server, and the endpoint decides what each user may see. The component asks for an `IRealtimeSource`, and each side registers its own. ## Install Install `Inngest.Blazor` in the project that holds your components. In an app that renders on the server and in WebAssembly that is the client project, which the server project references. The package brings in `Inngest`, and it references `Microsoft.AspNetCore.Components.Web` as a package rather than the ASP.NET Core shared framework, so it runs in the browser: ```bash dotnet add package Inngest.Blazor ``` The types of the SDK are in the `Inngest` namespace. Add `@using Inngest` to the components that use the view, as the samples do, and not to `_Imports.razor`, which would bring every name of the SDK, such as `Priority` and `Throttle`, into every file. ## Show a topic A function publishes to a [typed topic](https://damikun.github.io/inngest-dotnet/guides/realtime.md#channels-and-topics). The view takes the same topic, and the type of its data gives `live.Latest.Data` the same type: ```csharp public sealed record OrderStatus(string Text, int Percent); ``` ```csharp using System.Security.Claims; public interface IOrders { Task IsOwnedByAsync(string orderId, ClaimsPrincipal user); } ``` ```razor @page "/orders/{OrderId}" @rendermode InteractiveServer @attribute [Authorize] @using Inngest @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization @inject IOrders Orders @if (allowed) { @if (live.Latest?.Data is { } order) {

@order.Text

} else {

Waiting for the first update.

}
} else {

This order is not yours.

} @code { [CascadingParameter] private Task AuthenticationState { get; set; } = default!; [Parameter] public string OrderId { get; set; } = ""; private bool allowed; private RealtimeTopic status => new($"order:{OrderId}", "status"); protected override async Task OnParametersSetAsync() => allowed = await Orders.IsOwnedByAsync(OrderId, (await AuthenticationState).User); } ``` `OrderId` comes from the route, so the page checks that the signed-in user owns the order before it renders the view: on the server the view reads any channel, as [Blazor Server](https://damikun.github.io/inngest-dotnet/guides/blazor.md#blazor-server) explains. The samples below leave that check out and show only the view, which belongs in a page that makes it. `live` is the `RealtimeState` of the view, and the content is rendered again whenever it changes: | Member | Holds | | - | - | | `Connection` | A `RealtimeConnectionState`: `Idle`, `Connecting`, `Open`, `Reconnecting`, `Closed` or `Failed`. | | `Error` | The exception that failed the subscription, while `Connection` is `Failed`. | | `Messages` | The newest messages, the oldest first. At most `MaxMessages` of them. | | `Latest` | The newest message of any topic. | | `LatestByTopic` | The newest message of each topic, by topic name. | | `TextByTopic` | The text of the newest [stream](https://damikun.github.io/inngest-dotnet/guides/blazor.md#streams-of-text) of each topic, by topic name. | A `RealtimeMessage` has the `Kind`, `Channel`, `Topic`, `Data`, `RunId` and the other members described in [Realtime](https://damikun.github.io/inngest-dotnet/reference/api.md#realtime). The component has these parameters: | Parameter | Meaning | | - | - | | `Topic` | The `RealtimeTopic` to watch. Leave it null until you know what to watch. | | `Channel`, `Topics` | Several topics of one channel, instead of `Topic`. `TData` is then `JsonElement`. | | `MaxMessages` | How many of the newest messages `Messages` keeps. 100 by default. | | `OnMessage` | An `EventCallback>` called for each message, in order, after `live` has it. The next message waits until it returns. | | `ThrowOnError` | Hands a failed subscription to the nearest `ErrorBoundary`. See [Errors](https://damikun.github.io/inngest-dotnet/guides/blazor.md#errors). | | `ChildContent` | The content, given the `RealtimeState`. | The view draws nothing of its own, so it brings no markup or style to override. With no `ChildContent` it still calls `OnMessage`. ## Several topics A view holds one subscription, and a channel can carry several topics. Give the channel and the topics, and read the data as JSON, because the topics may carry different types: ```razor @using System.Text.Json @using Inngest @foreach (var (topic, message) in live.LatestByTopic) {

@topic: @message.Data

}
@code { [Parameter] public string OrderId { get; set; } = ""; } ``` The topics are compared by value, so a new array with the same topics on every render of the parent does not subscribe again. A different channel or other topics do: the old subscription is cancelled, the messages are cleared, and the view subscribes to the new ones. ## Streams of text A function can [stream text](https://damikun.github.io/inngest-dotnet/guides/realtime.md#publishing-from-a-function), such as a model's reply. It arrives as a `StreamStart`, `StreamChunk` messages with the text, and a `StreamEnd`. `TextByTopic` adds the chunks up for you, and it does not forget the early ones when `Messages` has rolled over: ```razor @using Inngest

@live.TextByTopic.GetValueOrDefault("reply")

@code { [Parameter] public string OrderId { get; set; } = ""; private RealtimeTopic reply => new($"order:{OrderId}", "reply"); } ``` The text of a topic starts over with the next stream. It stops growing at 1,048,576 characters, which is above what Inngest allows a stream. ## Blazor Server Register the source next to `AddInngest`. It reads Inngest with the app's signing key, so nothing of it reaches the browser: ```csharp builder.Services.AddInngest(options => options.AppId = "shop"); builder.Services.AddInngestRealtimeServerSource(); ``` That is all for Blazor Server and for the interactive server render mode. The view lives in the circuit: it updates itself through the renderer's dispatcher, and it unsubscribes when the component is disposed, which a circuit that ends does for all of its components. Disposing waits up to five seconds for the subscription to stop. A circuit whose client has disconnected is kept by Blazor for a few minutes, and keeps its subscriptions meanwhile. The source reads any channel with the app's signing key, whoever the user is. A page that takes the channel from a route value or a parameter, as the samples do, lets every user who can open it watch someone else's order unless it checks. Check in the page, before the view renders, as the first sample does with the `AuthenticationState`, or in a service the page asks. The endpoint of the [WebAssembly](https://damikun.github.io/inngest-dotnet/guides/blazor.md#webassembly) side has its own callback for this. Each view on the server holds its own token and its own connection to Inngest, for as long as the circuit shows it. The `MapInngestRealtime` relay and the SignalR bridge of `Inngest.SignalR` share one subscription for the same channel and topics, however many pages watch it. So a page with many views, or many users on one channel, costs a connection each here. To watch several topics of a channel use one view with a `Channel` and its `Topics`, as [below](https://damikun.github.io/inngest-dotnet/guides/blazor.md#several-topics), and not a view for each. ## WebAssembly A WebAssembly app has no signing key, so the server relays the messages. `MapInngestRealtime` comes from `Inngest.AspNetCore`, which the server project installs. Map an endpoint whose callback checks that the signed-in user may see the channel, and returns the channel and the topics the request may receive, or null for a 403: ```csharp app.MapInngestRealtime("/api/realtime/{channel}", context => { var channel = (string)context.Request.RouteValues["channel"]!; // Check here that the signed-in user may see the channel. return ValueTask.FromResult(new(channel, ["status", "reply"])); }) .RequireAuthorization(); ``` In the WebAssembly project, register the relay source and tell it where the endpoint for a channel and its topics is. An address without an origin is relative to the page: ```csharp services.AddInngestRealtimeRelaySource(options => { options.Url = (channel, topics) => new Uri($"/api/realtime/{Uri.EscapeDataString(channel)}", UriKind.Relative); }); ``` The browser sends the channel in the address, so the callback of the endpoint is the only thing that decides what a user sees. The topics a view asks for are only a request: the endpoint serves what its callback returns, and the source drops messages of other topics. The source reads the endpoint with an `HttpClient` and streams the response, as an `EventSource` would read it: - Cookies go with a request to the page's own origin. For an endpoint on another origin, `AddInngestRealtimeRelaySource` returns the builder of the `HttpClient`, so you can set its base address and add message handlers, such as one that attaches an access token or asks the browser to send credentials. The endpoint then needs CORS. - A connection that ends or fails is opened again after a delay that grows from half a second to 30 seconds, and one that stayed open for five seconds or more is opened again at once. The relay ends a client that falls 512 messages behind, and connections drop. What is published meanwhile is missed, as Inngest keeps no messages. A connection silent for 45 seconds, three of the relay's 15 second comments, is taken for dead. - A refusal is not retried: 401, 403, 404 and the other 4xx answers fail the subscription, and so does an answer that is not an event stream, such as the page of the app that a wrong address falls back to, or the login page that an endpoint requiring authorization redirects to when the user is not signed in. The 429, 408, 425 and 5xx answers of a relay or a proxy are retried. A 204 ends the subscription quietly, as it stops an `EventSource`. - A browser's `fetch` holds a whole response back unless it is asked to stream it. The source sets that option on every request, as .NET 8 needs and .NET 10 does by default. Typed topics are read with `RealtimeRelayOptions.SerializerOptions`. Blazor WebAssembly keeps reflection-based serialization on and trims only partly, so they are read without anything more. An app that trims fully (`TrimMode` full), or runs without reflection-based serialization, needs a source-generated context for the data types. Without one the view fails, with a `NotSupportedException` about the type when the first message arrives, or when it subscribes if reflection is off. Give the options the context: ```csharp using System.Text.Json.Serialization; [JsonSerializable(typeof(OrderStatus))] [JsonSerializable(typeof(string))] internal sealed partial class AppJsonContext : JsonSerializerContext; ``` ```csharp services.AddInngestRealtimeRelaySource(options => { options.Url = (channel, topics) => new Uri($"/api/realtime/{Uri.EscapeDataString(channel)}", UriKind.Relative); options.SerializerOptions.TypeInfoResolver = AppJsonContext.Default; }); ``` ## Interactive Auto An Auto page renders on the server first and in WebAssembly once the app has downloaded. Put the page in the client project, so that both sides can render it, and register one source in each project. The server project also maps the endpoint that the WebAssembly side reads: ```csharp // Program.cs of the server project builder.Services.AddInngest(options => options.AppId = "shop"); builder.Services.AddInngestRealtimeServerSource(); ``` ```csharp // Program.cs of the client project services.AddInngestRealtimeRelaySource(options => { options.Url = (channel, topics) => new Uri($"/api/realtime/{Uri.EscapeDataString(channel)}", UriKind.Relative); }); ``` The page that holds the view needs the render mode, because a view has a `RenderFragment` parameter, which cannot be passed to an interactive component from a static page: ```razor @page "/orders/{OrderId}" @rendermode InteractiveAuto @attribute [Authorize] @using Inngest @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization @inject IOrders Orders @if (allowed) {

@live.Latest?.Data?.Text

} @code { [CascadingParameter] private Task AuthenticationState { get; set; } = default!; [Parameter] public string OrderId { get; set; } = ""; private bool allowed; private RealtimeTopic status => new($"order:{OrderId}", "status"); // The server source reads any channel, so the page checks. In WebAssembly the endpoint's callback checks too, and // IOrders is then an implementation that asks the server. protected override async Task OnParametersSetAsync() => allowed = await Orders.IsOwnedByAsync(OrderId, (await AuthenticationState).User); } ``` When the page moves from the server to WebAssembly the view starts again on the other side: it holds nothing from the circuit, and what was published between the two is missed. ## Prerendering A view subscribes after its first interactive render and never before, so a page that is prerendered or rendered statically does not subscribe, and neither does the prerendered copy of an interactive one. The view's `Connection` reads `Idle` in the prerendered HTML. The view does not depend on `RendererInfo`, which .NET 8 lacks. A view asks for its `IRealtimeSource` when it subscribes, not when it is created. A server that only prerenders a page for WebAssembly therefore needs no source of its own, and a missing registration shows as a failed view with the missing service in `Error`, not as a page that does not render. ## Errors A subscription that is refused, such as one with a wrong signing key or a 403 from the relay, does not end the circuit or the page. The view logs a warning with the exception, `Connection` becomes `Failed`, and `Error` holds the exception: an `InngestException` on the server, and an `HttpRequestException` with a `StatusCode` for the relay. Its message is for the log, as it repeats what Inngest or the relay answered, so show the user a plain one and let them try again with `Restart`: ```razor @using Inngest @if (live.Connection == RealtimeConnectionState.Failed) {

Live updates stopped.

} else if (live.Connection == RealtimeConnectionState.Reconnecting) {

Reconnecting.

}
@code { private RealtimeView? view; [Parameter] public string OrderId { get; set; } = ""; private RealtimeTopic status => new($"order:{OrderId}", "status"); } ``` With `ThrowOnError`, the failure goes to the nearest `ErrorBoundary` instead, which shows its error content; without one it ends the circuit, as any unhandled exception in a component does. An exception from `OnMessage` fails the subscription the same way. `Connecting` reads while the subscription opens, and `Open` as soon as the connection is open, whether or not anything is published. Both sources say when a connection opens, is lost and opens again, so a view shows `Reconnecting` on the server and in WebAssembly. A source that cannot say, such as one over an implementation of `IInngestRealtime` that is not the SDK's own, leaves `Open` to the first message. ## Memory and threads A view keeps at most `MaxMessages` messages, one latest message for each topic, and the text of one stream for each topic. Everything the content reads is changed on the renderer's thread only, so it needs no lock: read it while rendering or in an event handler, and copy what you keep. Messages reach the renderer one at a time, and a slow renderer slows the reading down instead of piling messages up. ## Outside components `IRealtimeSource` gives the same messages as an `IAsyncEnumerable>`, to code that is not a component, such as a service shared by the server and the client project of an Auto app: ```csharp var status = new RealtimeTopic("order:o-1001", "status"); await foreach (var message in source.SubscribeAsync(status, cancellationToken)) { Console.WriteLine($"{message.Data?.Text} ({message.Data?.Percent}%)"); } ``` It has the two `SubscribeAsync` overloads of `IInngestRealtime`, for a typed topic and for several topics of a channel with the data as `JsonElement`, and reconnects the same way. Two more overloads take an `onConnectionChanged` callback before the token, which a source tells `Connecting`, `Open` and `Reconnecting`; an implementation of the interface writes those two and gets the others. In a test, register your own `IRealtimeSource` in the service collection of the test context and render the component with bUnit. ## What it does not do - It draws no markup and ships no JavaScript or CSS. - It does not replay anything. A message published before the view subscribed, while it reconnected, or while a page moved from the server to WebAssembly is missed, because Inngest delivers only to subscribers that are connected. - It reads the data of a topic as one type, and several topics as JSON. A channel and topics cannot be typed. - It does not pause when the browser tab is hidden, and it does not interpret the run messages of the `$run` topic or close when a run ends, as the TypeScript SDK's `useRealtime` hook can. - It does not carry messages from the prerendered page into the interactive one. - It does not mint subscription tokens for the browser: the WebAssembly side uses the relay, and the [TypeScript client](https://damikun.github.io/inngest-dotnet/guides/realtime.md#with-the-typescript-client) is the way to connect a browser to Inngest directly. --- # SignalR: realtime messages for SignalR clients > Forward Inngest realtime messages to the clients of a SignalR hub with Inngest.SignalR, authorize who may join a channel, and scale out on Redis or Azure SignalR Service without duplicates. Web page: https://damikun.github.io/inngest-dotnet/guides/signalr/ If your app already uses SignalR, `Inngest.SignalR` brings the [realtime](https://damikun.github.io/inngest-dotnet/guides/realtime.md) messages of your functions to its clients. A client calls a hub method to join the topics of a channel, and every message that a function publishes there reaches it as a call of a client method. The app decides who may join what, the browser holds no Inngest token, and clients that join the same topics share one subscription to Inngest. ## Set up Install `Inngest.SignalR` from NuGet. It brings in `Inngest` and `Inngest.AspNetCore`: ```bash dotnet add package Inngest.SignalR ``` Then add SignalR, the SDK and the bridge, and map the hub. `Authorize` is required, because nobody is let in by default: ```csharp title="Program.cs" using Inngest; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSignalR(); builder.Services.AddInngest(options => options.AppId = "shop"); builder.Services.AddInngestSignalR(options => { // Check here that the signed-in user may see the channel. options.Authorize = request => ValueTask.FromResult(request.Requested); }); var app = builder.Build(); app.MapInngestRealtimeHub("/hubs/inngest").RequireAuthorization(); app.Run(); ``` `MapInngestRealtimeHub` maps `InngestRealtimeHub`, a hub with two methods: `Subscribe(channel, topics)` and `Unsubscribe(channel, topics)`. Like any hub endpoint it follows the app's authorization, so `RequireAuthorization()` decides who may connect, and `Authorize` decides what a connected user may join. ## Decide who may join `Authorize` runs each time a client asks to join. The request holds the client's connection, with its user, the channel and topics it asked for, and a scope to resolve services from, such as a database context, which ends when the callback returns. The callback returns the subscription to join: - `request.Requested` lets the client in as it asked. - The same channel with fewer topics narrows the request. - Another channel gives the client something it did not name, such as one derived from its user. - `null` refuses, and so does a subscription with no topics. The client gets a `HubException` saying so. Throw a `HubException` yourself to say why; SignalR sends its message to the client. ```csharp using System.Security.Claims; public interface IOrderAccess { ValueTask CanSeeAsync(ClaimsPrincipal? user, string orderId, CancellationToken cancellationToken); } ``` ```csharp using Microsoft.AspNetCore.SignalR; builder.Services.AddInngestSignalR(options => { options.Authorize = async request => { var requested = request.Requested; if (!requested.Channel.StartsWith("order:", StringComparison.Ordinal)) { return null; } var orders = request.Services.GetRequiredService(); if (!await orders.CanSeeAsync(request.Caller.User, requested.Channel["order:".Length..], request.CancellationToken)) { throw new HubException("That order is not yours."); } // The tokens of a model's reply are for staff. return request.Caller.User?.IsInRole("staff") == true ? requested : requested with { Topics = [.. requested.Topics.Where(topic => topic != "tokens")] }; }; }); ``` The callback is asked when the client joins and not again while it stays, so a user whose access ends keeps what they joined until their connection ends. SignalR can end a connection when the authentication it was made with expires. Ask for that in the options of the connection, which `MapInngestRealtimeHub` passes on to `MapHub`: ```csharp app.MapInngestRealtimeHub("/hubs/inngest", options => options.CloseOnAuthenticationExpiration = true) .RequireAuthorization(); ``` ## Join from a client ### With the JavaScript client The client listens to `Realtime`, where each message arrives as the JSON Inngest sends it, in a string, and joins with `Subscribe`. The format is that of the [server-sent events relay](https://damikun.github.io/inngest-dotnet/guides/realtime.md#with-server-sent-events): `{ kind, channel, topic, data, run_id, created_at }`, and for the text of a stream `{ kind: "chunk", stream_id, data }` between a `datastream-start` and a `datastream-end`. ```js import * as signalR from "@microsoft/signalr"; const connection = new signalR.HubConnectionBuilder() .withUrl("/hubs/inngest") .withAutomaticReconnect() .build(); connection.on("Realtime", (json) => { const message = JSON.parse(json); if (message.kind === "data") showStatus(message.topic, message.data); if (message.kind === "chunk") appendText(message.data); }); const join = (channel, topics) => connection.invoke("Subscribe", channel, topics); const joinOrder = () => join(`order:${orderId}`, ["status", "tokens"]); // The client lost a subscription, and its connection is as it was. The server sends back the channel and topics it // asked for. After "behind" or "send-failed" messages were missed: join again and read the current state. After // "inngest-ended" Inngest refused or ended the subscription: try again later, not at once. connection.on("RealtimeError", (channel, topics, reason) => { if (reason === "inngest-ended") setTimeout(() => join(channel, topics), 10000); else join(channel, topics).then(loadState); }); // A reconnect is a new connection with a new id, and the server has forgotten the old one: join again. connection.onreconnected(joinOrder); await connection.start(); await joinOrder(); ``` `RealtimeError` carries the channel, the topics and one of these reasons: | Reason | Meaning | | - | - | | `behind` | The client fell `MaxQueuedMessagesPerConnection` messages behind. What was queued for it is dropped. | | `send-failed` | A message could not be sent to the connection. | | `inngest-ended` | Inngest refused or ended the subscription. | `Subscribe` resolves once the client has joined, and rejects with an error that holds the message of the `HubException` when it may not. A client that leaves a page calls `connection.invoke("Unsubscribe", channel, topics)` with the arguments it joined with, and a connection that closes leaves everything at once. Joining what a client is in already, and leaving what it is not in, change nothing. `Subscribe` returns as soon as the client has joined. It does not wait for the app's subscription to Inngest to open, and nothing tells the client when it has: there is no ready signal. Inngest delivers only to subscribers that are connected and keeps nothing for later, so a function that starts publishing right after `Subscribe` returns can publish its first messages before the subscription is open, and those are lost. Where the first messages matter, have the page read the current state after it has joined, and again after a reconnect or a `RealtimeError`, rather than rely on the stream alone. ### With the .NET client ```csharp using System.Text.Json; using Microsoft.AspNetCore.SignalR.Client; var connection = new HubConnectionBuilder() .WithUrl("https://shop.example.com/hubs/inngest") .WithAutomaticReconnect() .Build(); connection.On("Realtime", json => { using var message = JsonDocument.Parse(json); Console.WriteLine(message.RootElement.GetProperty("kind").GetString()); }); connection.On("RealtimeError", (channel, topics, reason) => connection.InvokeAsync("Subscribe", channel, topics)); connection.Reconnected += _ => connection.InvokeAsync("Subscribe", "order:o-1", new[] { "status" }); await connection.StartAsync(); await connection.InvokeAsync("Subscribe", "order:o-1", new[] { "status" }); ``` The JSON and MessagePack protocols carry a message alike, because it is a string. ## Join from a hub of your own An app that has a hub calls the bridge from its methods instead of mapping `InngestRealtimeHub`. The hub injects `InngestRealtimeBridge`, which `AddInngestSignalR` registers. A typed topic names the channel and topic once for the function that publishes and the hub that joins: ```csharp using Inngest; using Microsoft.AspNetCore.SignalR; public sealed record OrderStatus(string Text, int Percent); public static class Topics { public static RealtimeTopic Status(string orderId) => new($"order:{orderId}", "status"); } public sealed class OrdersHub(InngestRealtimeBridge bridge) : Hub { public Task Watch(string orderId) => bridge.SubscribeAsync(Context, Topics.Status(orderId), Context.ConnectionAborted).AsTask(); public void Unwatch(string orderId) => bridge.Unsubscribe(Context, Topics.Status(orderId)); } ``` ```csharp builder.Services.AddInngestSignalR(options => options.Authorize = request => ValueTask.FromResult(request.Requested)); app.MapHub("/hubs/orders"); ``` `SubscribeAsync` asks the same `Authorize` callback, returns the subscription that was joined, and throws the `HubException` the client sees. The hub needs no base class and no filter: the bridge leaves a connection's subscriptions when SignalR aborts the connection, whichever hub it belongs to. Messages reach the client method `Realtime` unless `MessageMethod` says otherwise; `ErrorMethod` names the one for errors. ## What the bridge does - **One subscription to Inngest for each channel and set of topics**, whatever the order of the topics. It opens with the first client that joins, and closes with the last that leaves or disconnects. Subscriptions that overlap, such as `[status]` and `[status, tokens]` of one channel, are two subscriptions, and a client that joined both receives a `status` message once for each. - **Every connection has a queue of its own** and a loop that sends it to the client, one message at a time and in order. A client that reads slowly delays only itself. One that falls `MaxQueuedMessagesPerConnection` (512) messages behind loses its subscriptions: the queue is emptied, and the client hears of each subscription in `RealtimeError(channel, topics, "behind")`, with the channel and topics it asked for, to join again with the same call. A send that throws does the same, with the reason `send-failed`. The connection stays open, for the app may use it for more than this, and the bridge never ends it: `HubCallerContext.Abort` tells a client not to reconnect, even one with automatic reconnect. The messages the client missed are gone, so it reads the current state when it joins again. Sends that a backplane or service stops accepting fill the queues of all the clients in the same way. - **A subscription Inngest refuses**, such as one with a signing key it does not accept, is logged. Every client that had joined it hears of it in `RealtimeError(channel, topics, "inngest-ended")`, once for each subscription it had in it, and is out of it. Joining again tries anew, and a refusal that lasts is refused again, so wait before it. - **A subscription that drops** is opened again with a new token, as `SubscribeAsync` does, and what is published meanwhile is missed. A client whose connection drops is a new connection when it returns, and joins again. - **When the app stops**, the subscriptions to Inngest close at once and nobody joins any more. - **A connection may be in 32 subscriptions** by default, counting each way it asked to join, for each can be a connection from the app to Inngest that nobody else shares. `MaxSubscriptionsPerConnection` changes it. ## Scale out SignalR serves a connection from one app instance. With several instances, a backplane lets one instance send to a connection, a user or a group that is on another: Redis, or Azure SignalR Service, which holds the client connections itself. See [Microsoft's guide](https://learn.microsoft.com/aspnet/core/signalr/scale). Every instance of the app subscribes to Inngest for the clients it has, so a bridge that sent each message to a group would deliver it again and again: a send to a group reaches its members on every instance, whichever sent it. With the same group name on three instances that all forward a message, a client would receive it three times. So the bridge never sends to a group or to everyone. An instance sends each message to the connections that joined through it, by connection id, and the other instances do the same for theirs. Each client receives each message once for each of its subscriptions that includes the topic, however many instances there are, on Redis and on Azure SignalR Service. What that costs and saves: - **Redis.** A send to a connection that is on the instance is written straight to it, without Redis, so the bridge's messages put no load on Redis and do not wait for it. A group send is published to Redis, where every instance that has a member of the group delivers it. - **Azure SignalR Service.** The instance sends the service one message for each client, and the service delivers it. A group send would be one message for all its members, which is cheaper for the instance when a channel has a great many clients. - **Instance-scoped group names** (`:`) would also be exact, and send one message for all the clients of an instance. They need the backplane when a client joins or leaves: Redis subscribes a group's channel for the first member on a server and drops it with the last, and Azure SignalR Service takes a round trip to the service for each join and leave. And a group send waits for every member's write, on the default in-process manager and in the handler Redis runs on each server, so a client that reads slowly holds up the others of its group. The bridge sends to connections so that it can cut that one client off from its subscriptions and no other. - **Each instance opens its own subscription to Inngest** for each channel its clients joined, so a channel that clients of three instances watch has three subscriptions, not one. - **A reconnect** can land on another instance, with a new connection id. The client joins again, which opens a subscription on that instance if it has none. The instance of the old connection leaves its subscriptions when SignalR ends that connection. If thousands of clients watch one channel, consider letting their browsers subscribe to Inngest themselves with a token and `useRealtime`: Inngest does the fan-out, and no instance of your app sends a message for each client. ## Trimming and Native AOT The package's own code is trim and Native AOT compatible. SignalR's own support is narrower: - The ASP.NET Core documentation lists SignalR as not supported with Native AOT on .NET 8. .NET 9 added trimming and Native AOT for the SignalR server and client. - Only the JSON protocol, with a `System.Text.Json` source-generated context. MessagePack is not supported. - No strongly typed hubs with Native AOT, and the asynchronous return types of hub methods are limited to `Task`, `Task`, `ValueTask` and `ValueTask`. - Every type that crosses the wire needs a place in the context, registered with SignalR's JSON protocol. In a trimmed or Native AOT app `AddInngestSignalR` registers the two types of its own hub, `string` and `string[]` (the text of a message and the topics of `Subscribe`), after your context. A hub of your own and its client calls add their types to the context as they would without the bridge: ```csharp using System.Text.Json.Serialization; public sealed record OrderRef(string OrderId); [JsonSerializable(typeof(OrderRef))] internal sealed partial class HubJsonContext : JsonSerializerContext; ``` ```csharp using Microsoft.AspNetCore.SignalR; builder.Services.Configure(options => options.PayloadSerializerOptions.TypeInfoResolverChain.Insert(0, HubJsonContext.Default)); ``` See [Native AOT](https://damikun.github.io/inngest-dotnet/guides/native-aot.md) for the SDK itself. ## Compared with server-sent events [`MapInngestRealtime`](https://damikun.github.io/inngest-dotnet/guides/realtime.md#with-server-sent-events) relays the same messages as server-sent events, with no client library. | | `MapInngestRealtime` | `Inngest.SignalR` | | - | - | - | | Client | `EventSource` | `@microsoft/signalr` or the .NET client | | What a connection receives | The topics of one channel, named by the URL | Any channels, joined and left by calls on one connection | | Who decides | A callback on the request, and the endpoint's authorization | A callback on each join, and the hub's authorization | | After a drop | The browser reconnects, and the callback runs again | The client reconnects and joins again | | Subscriptions to Inngest | One for each channel and topics, shared | One for each channel and topics, shared, in each instance | | A slow client | Disconnected after 512 messages | Loses its subscriptions after 512 messages and is told, and stays connected | | With several instances | Nothing to set up | Each instance sends only to its own clients | SignalR suits an app that has it already and wants these messages on the connection the page uses for the rest. A page that only listens needs nothing but `EventSource`. A browser can also subscribe to Inngest itself with a token and `useRealtime`, as [Realtime](https://damikun.github.io/inngest-dotnet/guides/realtime.md#with-the-typescript-client) shows, which leaves the fan-out to Inngest. ## Limits - Each message is a string of the JSON Inngest sent, up to Inngest's own limit of 512 KB. SignalR's `MaximumReceiveMessageSize` applies to what clients send, such as the topics of `Subscribe`. - Delivery follows Inngest's: to the clients that have joined, from the moment the subscription to Inngest is open, and nothing is kept for later. - A client that joined through one instance of the app is served by that instance until its connection ends. --- # Durable endpoints in ASP.NET Core > Make an ASP.NET Core endpoint durable, so that its handler runs steps like an Inngest function, answers the caller directly and hands the run to Inngest when a step has to wait or fails. Web page: https://damikun.github.io/inngest-dotnet/guides/durable-endpoints/ A durable endpoint is an HTTP endpoint whose handler runs steps, as an Inngest function does. The caller gets the handler's response directly. Inngest takes the run over only when a step has to wait or fails. It finishes the run, and the caller is sent to a URL that answers with the final response. A function started by an event works the other way round: the sender gets no result, and Inngest calls the app for every step. A durable endpoint suits work that a caller waits for, which Inngest advises to keep under 60 seconds. Work that nobody waits for, or that takes longer, belongs in a function. ## Beta Inngest calls durable endpoints beta, so the protocol and the .NET API may still change. The API is marked `[Experimental("INNGEST001")]`. Code that uses it, from `UseInngestDurableEndpoints` and `WithDurableEndpoint` to `DurableEndpointOptions` and the `[DurableEndpoint]` attribute, is a compile error until the app suppresses that diagnostic. Suppress it in the project file: ```xml title="Shop.csproj" $(NoWarn);INNGEST001 ``` Or put `#pragma warning disable INNGEST001` above the code that uses it. [Durable Endpoints](https://www.inngest.com/docs/durable-execution/durable-endpoints) in the Inngest docs describes the feature itself. ## Setup Durable endpoints come with `Inngest.AspNetCore`, which brings in `Inngest`. [Getting started](https://damikun.github.io/inngest-dotnet/guides/getting-started.md) shows how to install it and start the dev server. Three things make an endpoint durable: `AddInngest` registers the SDK, `UseInngestDurableEndpoints` adds the middleware, and the endpoint is marked with `[DurableEndpoint]` or `WithDurableEndpoint`. The handler takes the step tools as an `IStepTools` parameter. ### Attributes On a controller, the attribute goes on the action. Without an id it can go on the controller, which marks every action: ```csharp title="Program.cs" using Inngest; var builder = WebApplication.CreateBuilder(args); // Register IInventory and IPaymentGateway as usual. builder.Services.AddControllers(); builder.Services.AddInngest(options => options.AppId = "shop"); var app = builder.Build(); app.UseInngestDurableEndpoints(); app.MapControllers(); app.Run(); ``` ```csharp title="CheckoutController.cs" using Inngest; using Microsoft.AspNetCore.Mvc; [ApiController] [Route("checkout")] public sealed class CheckoutController(IInventory inventory, IPaymentGateway payments) : ControllerBase { [HttpPost] [DurableEndpoint("checkout")] public async Task Post(CheckoutRequest request, [FromServices] IStepTools step) { var reservationId = await step.RunAsync("reserve-stock", ct => inventory.ReserveAsync(request.OrderId, ct)); var paymentId = await step.RunAsync("charge-card", ct => payments.ChargeAsync(request.CustomerId, request.Total, ct)); return Ok(new { request.OrderId, reservationId, paymentId }); } } public sealed record CheckoutRequest(string OrderId, string CustomerId, decimal Total); ``` ### Code first On a minimal API endpoint, call `WithDurableEndpoint`: ```csharp title="Program.cs" using Inngest; var builder = WebApplication.CreateBuilder(args); // Register IInventory and IPaymentGateway as usual. builder.Services.AddInngest(options => options.AppId = "shop"); var app = builder.Build(); app.UseInngestDurableEndpoints(); app.MapPost("/checkout", async (CheckoutRequest request, IStepTools step, IInventory inventory, IPaymentGateway payments) => { var reservationId = await step.RunAsync("reserve-stock", ct => inventory.ReserveAsync(request.OrderId, ct)); var paymentId = await step.RunAsync("charge-card", ct => payments.ChargeAsync(request.CustomerId, request.Total, ct)); return Results.Ok(new { request.OrderId, reservationId, paymentId }); }) .WithDurableEndpoint("checkout"); app.Run(); public sealed record CheckoutRequest(string OrderId, string CustomerId, decimal Total); ``` `UseInngestDurableEndpoints` goes after routing, because it looks for the endpoint the request was routed to. A `WebApplication` adds routing on its own; with another host, call `UseRouting` first. Everything after it in the pipeline runs inside the run. Put exception handling, such as `UseExceptionHandler`, before it, so that the SDK sees an exception first and can have it retried. `MapInngest` is not needed. Inngest calls a durable endpoint at its own URL and learns of it when the first run is saved, so there is nothing to sync. An app that has functions as well maps `MapInngest` as usual. `AddInngest` registers `IStepTools` as a service that resolves to the steps of the call that is running. Resolving it outside a durable endpoint or a function throws an `InvalidOperationException`. The step tools are the ones a function uses; see [Steps](https://damikun.github.io/inngest-dotnet/guides/steps.md). To try it, run the app in dev mode and start the dev server, as in [Getting started](https://damikun.github.io/inngest-dotnet/guides/getting-started.md). `curl -L` follows the redirect that comes when a step has to wait: ```bash curl -L -H 'Content-Type: application/json' -d '{"orderId":"o-1001","customerId":"c-42","total":59.90}' http://localhost:5280/checkout ``` The run appears under **Runs** in the dev server. If the dev server runs in Docker, set `ServeOrigin` first, as described under [Configuration](https://damikun.github.io/inngest-dotnet/guides/durable-endpoints.md#configuration). ## What happens to a request A request starts a run. The handler runs inside the request, and so do the steps that run your code, such as `RunAsync`. The SDK keeps their results in memory and tells Inngest about the run only when it has to hand the run over, or when the endpoint is done. ### The endpoint finishes The client gets the response, with the run's id in an `X-Run-ID` header. After the response, the SDK records the run in Inngest, with the steps that ran and the response. A request that ran no steps leaves no run, and its response has no `X-Run-ID`. If Inngest cannot be reached for the recording, the SDK logs it. The client has its response already. ### A step has to wait A sleep, a wait for an event or a signal, an invoke, a fetch or inference step, and steps in parallel do not run in the request: Inngest carries them out. The SDK saves the steps so far, which creates the run in Inngest, and the client gets a 303 See Other to a URL that answers with the final response. That URL waits up to five minutes for the run to finish. This handler runs `reserve-stock` in the request and then waits for a person to approve a large order. The body of the handler: ```csharp var reservationId = await step.RunAsync("reserve-stock", ct => inventory.ReserveAsync(request.OrderId, ct)); if (request.Total > 1000m) { var approval = await step.WaitForSignalAsync( "wait-for-approval", new WaitForSignalOptions($"approve-order-{request.OrderId}", TimeSpan.FromMinutes(2))); if (approval is null) { await step.RunAsync("release-stock", ct => inventory.ReleaseAsync(reservationId, ct)); return Results.StatusCode(StatusCodes.Status408RequestTimeout); } } var paymentId = await step.RunAsync("charge-card", ct => payments.ChargeAsync(request.CustomerId, request.Total, ct)); return Results.Ok(new { request.OrderId, reservationId, paymentId }); ``` A small order never leaves the request. For a large one the client gets the 303 as soon as the handler reaches `wait-for-approval`. Another request ends the wait by sending the signal: ```csharp app.MapPost("/orders/{orderId}/approve", async (string orderId, IInngestClient inngest, CancellationToken cancellationToken) => { var runId = await inngest.SendSignalAsync(new InngestSignal($"approve-order-{orderId}"), cancellationToken); return runId is null ? Results.NotFound() : Results.Accepted(); }); ``` If Inngest cannot be reached when the run is handed over, the request fails with that exception, which the app's error handling answers. ### Inngest calls the endpoint again When the run can go on, Inngest calls the endpoint again, at the same URL, with the original method, body and query. The handler runs from the top, as a function does on every call. Steps that already ran return their saved results and do not run again. The code between steps does run again, so keep side effects in steps, as [Steps](https://damikun.github.io/inngest-dotnet/guides/steps.md#keep-side-effects-in-steps) explains. When the handler returns, its response goes to Inngest, which keeps it for the client that waits at the URL. In cloud mode the SDK checks the signature of the call, as it does for every call from Inngest. ### A step fails Inngest retries a step that throws while it has retries left, so a step that fails can run again: make its effects safe to repeat. The SDK saves the failure, which hands the run over, and the client waits as above. When the last attempt fails, Inngest calls the endpoint again and the step throws a `StepFailedException` where the handler awaits it, as in a function. The handler can catch it and answer: ```csharp try { var paymentId = await step.RunAsync("charge-card", ct => payments.ChargeAsync(request.CustomerId, request.Total, ct)); return Results.Ok(new { request.OrderId, paymentId }); } catch (StepFailedException exception) { return Results.Conflict(new { request.OrderId, error = exception.Error.Message }); } ``` Left uncaught, it fails the run. Throw `NonRetriableException` in a step to fail it at once. See [Errors and retries](https://damikun.github.io/inngest-dotnet/guides/errors-and-retries.md). ### The endpoint throws While attempts remain, Inngest retries an exception from the handler. The SDK hands the run over, Inngest calls the endpoint again, and the client waits as above. The steps that ran return their saved results. When the request itself is the last attempt, because `Retries` is 0 or the exception is a `NonRetriableException`, the exception goes to the app's own error handling, which answers the client as it would without Inngest. If the request ran steps, the SDK records the run as failed, with the error. A call from Inngest that throws on the last attempt fails the run. Only an exception makes Inngest retry. A handler that returns a 500 response has finished, and the run records that response. ## What Inngest stores Inngest creates the run from an `inngest/http.request` event. Its data holds the method, the path, the query, the content type, the body as base64, the address of the client and the origin that Inngest calls back. Inngest keeps them with the run and sends the method, content type, body and query back on every call that resumes it. The request's headers are not stored. Treat the body and the query like step results: Inngest stores them. The run does not belong to the app that `MapInngest` serves. Inngest creates an app named after the domain of the endpoint, with one function for each endpoint id. ## Options `WithDurableEndpoint` takes `DurableEndpointOptions`, or only the id as a string. `[DurableEndpoint]` takes the id as its argument, with `Retries` and `AsyncResponse` as properties. | Option | Meaning | Default | | - | - | - | | `Id` | The id of the endpoint's function in Inngest. | The method and the route pattern, such as `POST /orders/{id}` | | `Retries` | How many times Inngest retries a failed step, or the endpoint when it throws, after the first attempt. | 3 | | `AsyncResponse` | What the client gets when Inngest takes the run over: `Redirect` or `Token`. | `Redirect` | | `RedirectUrl` | Where the redirect goes. | The output URL on the Inngest API | | `MaxRequestBodySize` | The largest request body, in bytes. A larger request is refused with 413. | 1 MiB | ### Attributes ```csharp [DurableEndpoint("checkout", Retries = 5, AsyncResponse = DurableEndpointResponse.Token)] ``` `RedirectUrl` and `MaxRequestBodySize` have no attribute property. Set them with `DurableEndpointOptions`. ### Code first ```csharp new DurableEndpointOptions { Id = "checkout", Retries = 5, AsyncResponse = DurableEndpointResponse.Token, MaxRequestBodySize = 256 * 1024, } ``` ### The function id Without an `Id`, the id is the method and the route pattern, not the path, so `POST /orders/1` and `POST /orders/2` are one function, `POST /orders/{id}`. Set `Id` to name the function yourself. Inngest knows the function by its id, so keep it stable. ### Retries The count applies to each step on its own, as it does for functions, and to the endpoint when it throws. Without `Retries` it is 3, one fewer than for functions, as in the TypeScript SDK. With 0, a step that fails is final. ### What the client gets when Inngest takes over `Redirect`, the default, answers 303 See Other with a `Location` header. A 303 makes every client fetch the output with a GET, whatever the method of the request. `HttpClient` and `fetch` follow it by themselves, and so does `curl` with `-L`. The default location is `{ApiBaseUrl}/v1/http/runs/{run id}/output?token={token}`: the Inngest API, which in dev mode is the dev server. `Token` answers 200 with JSON, for a client that waits for the run itself: ```json { "run_id": "01JEXAMPLERUNID000000000000", "token": "eyJ..." } ``` The client reads the response from the output URL above with a GET. The token is all it needs, so treat it like the response itself. Both answers carry the run's id in `X-Run-ID`. They keep the headers that middleware before `UseInngestDurableEndpoints` set, such as security headers, and leave out those the endpoint set before the hand-over. `RedirectUrl` replaces the location of the redirect. It gets a `DurableEndpointRun` with the run's id and token and returns the `Uri` to redirect to, such as a page of your own app: ```csharp new DurableEndpointOptions { RedirectUrl = run => new Uri($"https://shop.example.com/checkout/{run.RunId}?token={Uri.EscapeDataString(run.Token)}"), } ``` The default location is not on the app's origin, so a script in a browser follows the redirect under cross-origin rules. ### Request size Inngest stores the request's body and sends it back on every call that resumes the run, so the body has a limit. The SDK reads the whole body into memory before the handler runs, and refuses one larger than `MaxRequestBodySize` with 413\. This holds for every request to the endpoint, whether or not it ends up waiting. Inngest accepts about 3 MiB at most. ## Configuration The run records the origin that Inngest calls the endpoint back on. By default that is the origin of the request. Behind a reverse proxy, a load balancer or in a container, it may be an address that Inngest cannot reach. Set `ServeOrigin`, or `INNGEST_SERVE_ORIGIN`, to the public origin, as for [syncs](https://damikun.github.io/inngest-dotnet/guides/configuration.md#behind-a-proxy): ```csharp options.ServeOrigin = new Uri("https://shop.example.com"); ``` Only the scheme, host and port of `ServeOrigin` are used. The path comes from the request. The proxy has to pass on the headers of the calls from Inngest, `X-Run-ID` and `X-Inngest-Signature` among them. A call without them is taken for a new request from a client, which starts a second run. In dev mode the SDK talks to the dev server, so the redirect goes to the dev server, at `http://localhost:8288` unless `DevServerUrl` says otherwise. A dev server in Docker reaches the app as `host.docker.internal`, not as `localhost`, so set `ServeOrigin` to `http://host.docker.internal:5280` for the app of the example above. In cloud mode the endpoint needs the signing key, as functions do: it authenticates the SDK's calls to the Inngest API, and the SDK verifies the calls from Inngest with it. See [Configuration](https://damikun.github.io/inngest-dotnet/guides/configuration.md). ## Limitations - **No flow control, `defer` or `group.experiment`.** Inngest does not support them for durable endpoints. The options above are all that an endpoint has. - **The response is buffered and recorded as text.** The SDK holds the whole response in memory until the endpoint has finished, so streaming responses, such as server-sent events, do not work. Inngest records the body as text, so do not return binary content from a durable endpoint. - **A call that resumes a run has no original headers and no user.** It carries the method, the body and the query, and nothing else of the request. A handler that needs the user, or a header, reads it in a step before its first wait. The saved result then comes back on every call: ```csharp app.MapPost("/orders/{orderId}/ship", async (string orderId, HttpContext http, IStepTools step, IEmailSender email) => { var recipient = await step.RunAsync("read-recipient", () => http.User.FindFirst("email")?.Value ?? ""); await step.SleepAsync("wait-for-packing", TimeSpan.FromSeconds(30)); await step.RunAsync("send-confirmation", ct => email.SendAsync(recipient, "Your order has shipped", ct)); return Results.Accepted(); }) .WithDurableEndpoint(); ``` - **Endpoints that require authorization cannot be resumed yet.** The call from Inngest has no user, so authorization refuses it. Such an endpoint works only while all of its steps run in the request. - **Code that outlives the call must not use `HttpContext`.** When a run is handed over, the handler is abandoned at its step, and ASP.NET Core reuses the `HttpContext` once the request is over. Code the handler started that goes on running, such as a task that is not awaited, must not touch it. - **Steps are saved when the run is handed over or the endpoint has finished.** Until then they wait in memory. If the process stops or the client disconnects before that, Inngest knows nothing of the run, and the client's retry runs the steps again. - **`InngestTestHost` does not run durable endpoints yet.** Test an endpoint against the dev server, as described in [Testing](https://damikun.github.io/inngest-dotnet/guides/testing.md#end-to-end-against-the-dev-server). --- # Chat clients as steps with Microsoft.Extensions.AI > Make the model calls and tool calls of an IChatClient steps of an Inngest run, so that a retry or a replay never calls the model or a tool twice. Web page: https://damikun.github.io/inngest-dotnet/guides/ai/ Inngest runs a function by calling it again after every step, so a call to a model that is not in a step is made again on every call, and paid for again. The [`Inngest.Extensions.AI`](https://www.nuget.org/packages/Inngest.Extensions.AI/) package puts the calls of an `IChatClient` of [Microsoft.Extensions.AI](https://learn.microsoft.com/dotnet/ai/microsoft-extensions-ai) in steps: each call to the model, and each tool the model asks for, is made once, saved with the run and handed back from it on every replay. If a later step fails, its retry does not call the model again. For a model call you make yourself, `AIWrapAsync` and `InferAsync` do the same one call at a time; see [Calls to AI models](https://damikun.github.io/inngest-dotnet/guides/steps.md#calls-to-ai-models). This package does it for a whole chat pipeline, tool loop included. ## Setting it up ```bash dotnet add package Inngest.Extensions.AI ``` The package builds on Microsoft.Extensions.AI. On net8.0 that brings System.Text.Json 10 with it, which is newer than the one the runtime ships, so an app on net8.0 that adds the package runs on the newer one. It adds two stages to a `ChatClientBuilder`. Stages added first are the outermost, so the stage that runs the tools goes first and calls the stage that makes the model call a step, once for each round: ```csharp using Microsoft.Extensions.AI; builder.Services .AddChatClient(model) .UseInngestFunctionInvocation() // the tools the model asks for, each a step .UseInngestSteps() // each call to the model, a step .UseOpenTelemetry() // sees only the calls that reach the model .UseLogging(); ``` `UseInngestFunctionInvocation` takes the place of `UseFunctionInvocation`. It adds a `FunctionInvokingChatClient`, so its callback changes the same properties, such as `MaximumIterationsPerRequest` and `AllowConcurrentInvocation`. A pipeline with `UseFunctionInvocation` instead still has its model calls saved, but runs its tools in the function itself, on every call, and a tool that charges a card or sends mail then does it again on every replay. Then use the client in a function as you would anywhere else: ```csharp using Microsoft.Extensions.AI; var reply = await chat.GetResponseAsync( $"Write a short thank-you note for order {context.Event.Data.OrderId}.", cancellationToken: cancellationToken); await context.Step.RunAsync("send-note", ct => email.SendAsync(context.Event.Data.CustomerId, reply.Text, ct)); ``` Outside a run, and inside the handler of a step, where steps cannot be nested, the stages change nothing, so the same client serves a controller, a unit test and a function. ## What the steps are | Call | Step | Input the Inngest UI shows | Saved with the run | | - | - | - | - | | `GetResponseAsync` | `chat`, then `chat:1`, `chat:2`, ... | `{"messages": [...], "options": {...}}` | The `ChatResponse` as JSON, and a digest of the input | | `GetStreamingResponseAsync` | The same ids | The same | The whole response, as JSON, and the digest | | A tool call | `tool `, then `tool :1`, ... | `{"name": ..., "callId": ..., "arguments": {...}}` | `{"result": ..., "inputDigest": ...}` | All of them are [AI wrap steps](https://damikun.github.io/inngest-dotnet/guides/steps.md#calls-to-ai-models), so the Inngest UI shows them as calls to a model with their input. The ids follow the rules of any [step id](https://damikun.github.io/inngest-dotnet/guides/steps.md#step-ids): the same call gets the same id on every replay, and the SDK numbers repeats in the order they are made. The digest is a hash of the input, not the input, written as `"inngest": {"inputDigest": ...}` next to the response, which readers of a `ChatResponse` skip. A replay compares it with the input of the call that gets the result, to catch the mix-up that [Naming a step](https://damikun.github.io/inngest-dotnet/guides/ai.md#naming-a-step) describes. The messages, options and responses are written with `AIJsonUtilities.DefaultOptions` of Microsoft.Extensions.AI, which knows `AIContent` and its subtypes. Text, reasoning, function calls and results, usage and the finish reason all make it through, and the app registers nothing for them in its `JsonSerializerOptions`. Those options have source-generated contracts, so this works under [Native AOT](https://damikun.github.io/inngest-dotnet/guides/native-aot.md) too. The SDK itself only handles `JsonElement`. What JSON cannot hold does not come back, the same as with the caching clients of Microsoft.Extensions.AI: `RawRepresentation` of responses, messages and content is gone, and the values in `AdditionalProperties` come back as `JsonElement`s. The call that makes a step gets the saved response too, as the result of every step is read back from JSON, so the code between calls sees the same on the first call and on replays. ## Naming a step Without a name, the model calls of a run are `chat`, `chat:1` and so on, in the order they reach the client. That order is the same on every replay when nothing but steps is awaited before the calls: one after the other, or together with `Task.WhenAll` over calls that the function makes at once. It is not when something else is awaited first, such as a database query, an `HttpClient` request or a `Task.Delay`. Calls like that reach the client in the order those finish, which differs from one replay to the next, so which of them gets `chat` and which `chat:1` changes, and each can be handed the response that was made for another. Name each such call, so that its id does not depend on timing: ```csharp using Microsoft.Extensions.AI; string[] userIds = ["u-1", "u-2", "u-3"]; // Each call waits for a lookup of its own, so the calls reach the client in the order the lookups finish. var notes = await Task.WhenAll(userIds.Select(async userId => { var profile = await profiles.LoadAsync(userId, cancellationToken); return await chat.GetResponseAsync( $"Write a short thank-you note for {profile.Name}.", new ChatOptions().WithInngestStepId($"note-{userId}"), cancellationToken); })); ``` `WithInngestStepId` puts the name in `ChatOptions.AdditionalProperties`, as a string, and the client takes it out before the options reach the model, so a provider never sends it on. A name gets `:1`, `:2` for repeats like any id. Naming calls is also how to tell them apart in the Inngest UI. To catch the mix-up, each saved result carries a digest of the input it was made for. When a replay hands a call a result with another digest than its own input has, the client logs a warning that names the step and `WithInngestStepId`. The warning comes from a logger that is not silenced while the run replays, so it appears once for each such step in each call of the function. It is a warning and not an exception, because a prompt with a part that changes between replays, such as the time, would then fail the run on every replay: it gets the warning on every replay, and the fix is to name the call, to keep the changing part out of the prompt, or to ignore the warning. An input edited in the Inngest UI does not change the digest, which is that of the input the code asked for. Tool steps are named for the tool, and a model that calls the same tool twice in one response gets the ids in the order of its calls, so they need no name. Conversations that run side by side would claim the same tool ids in no fixed order, so a conversation that you name carries its name into its tool steps: with `WithInngestStepId("plan")` the steps are `plan`, `plan tool get_weather`, `plan:1`. The result of a tool is saved with a digest of its arguments and checked in the same way. ## Streaming A streaming call inside a run is one step that reads the model's whole stream, saves the complete response and then hands it out as updates. The consumer gets the updates when the model has finished, not while it writes. A stream that nobody enumerates is not a call, as with any `IChatClient`. To show the text while the model writes it, publish it to a [realtime](https://damikun.github.io/inngest-dotnet/guides/realtime.md) topic from the step: ```csharp using Microsoft.Extensions.AI; var tokens = new RealtimeTopic($"order:{context.Event.Data.OrderId}", "tokens"); var reply = await chat .GetStreamingResponseAsync("Summarize the order.", new ChatOptions().WithInngestRealtimeStream(tokens), cancellationToken) .ToChatResponseAsync(cancellationToken); ``` Subscribers receive the text as a stream, as `PublishStreamAsync` sends it. A model sends an update every few characters, and Inngest takes at most 1,000 chunks of up to 1 KB in a stream, so the text is gathered into chunks. A chunk goes out when it is full, or when the text has waited 100 milliseconds, and the wait grows to a second once the stream has 500 chunks, so that a long answer does not use them up. A text that still outgrows the limit, which is about a megabyte, is published up to it and the rest is left out, with a warning in the log. The model call is not affected. This is why the step publishes through `IInngestRealtime` and does not use the `PublishStreamAsync` step tool: a step cannot be called inside the handler of another, and the model call is the step that reads the stream. A publishing step of its own would also have to save the text, which leaves out the function calls, usage and finish reason of the response, and the two steps could disagree after a failure. If the model fails in the middle of its stream, the step is retried, and the retry publishes the stream again from the start. Inngest ends a publish request that stops early like one that finished, so subscribers see the first stream end where the model failed, with the text up to there, and then a second stream with the whole text. The streams have different `StreamId`s and the same `RunId`, so a page that shows the text starts over at each `StreamStart` of its run. A replay publishes nothing. Failing to publish is logged as a warning and does not fail the model call, which would otherwise be made, and paid for, again. ## Tools A tool that the model asks for is a step named `tool `. The arguments are its input, and the result is saved as `{"result": ...}`, so a replay hands the model the result without running the tool. The result comes back as a `JsonElement`, which is what a function made with `AIFunctionFactory` returns anyway; a tool without a result returns `null`. A tool that throws is retried like any step, up to the function's retries, and when they are used up `FunctionInvokingChatClient` reports the failure to the model, as it does for any tool that throws. An `ArgumentException` is the exception to the retries: the model chose the arguments, so the same ones fail again, and the step fails at once. Throw `NonRetriableException` from a tool to skip the retries for another reason. The tools run in the handler of their step, outside the async flow of the function, so state that the function keeps in async-local variables is not visible to them. `FunctionInvokingChatClient.CurrentContext` is set there, so a tool can still ask the client to stop calling the model by setting `Terminate`, and a replay stops where the run did. A tool that changes the context of its call, which `FunctionInvokingChatClient.CurrentContext` gives it, such as its `Options` or `Messages`, changes it only when the step runs. A replay hands back the saved result and does not run the tool, so the context stays as the client made it. Only `Terminate` is saved with the result and set again. Tools the model asks for in one response are steps like any others made together: set `AllowConcurrentInvocation` and they run as parallel steps, each in a call of its own. Without it they run one after the other, each a step, and every step is a call to your function, unless [checkpointing](https://damikun.github.io/inngest-dotnet/guides/configuration.md) runs consecutive steps in one request. ## Letting Inngest call the model `InngestInferChatClient` is an `IChatClient` whose calls Inngest makes. Each call is an [inference step](https://damikun.github.io/inngest-dotnet/guides/steps.md#calls-to-ai-models): Inngest sends the request to a provider that speaks the OpenAI chat completions API, and no worker waits for the answer. The client turns the messages, tools and options into the request body and reads the response back into a `ChatResponse`, with its tool calls and usage. ```csharp using Microsoft.Extensions.AI; IChatClient chat = new ChatClientBuilder(new InngestInferChatClient( new Uri("https://api.openai.com/v1/chat/completions"), "gpt-4o-mini", apiKey)) .UseInngestFunctionInvocation() .Build(); ``` It is a step already, so it needs no `UseInngestSteps`; `UseInngestFunctionInvocation` still makes the tools steps. Like every inference step it works inside a run only and throws elsewhere. Inngest stores the request with the run, the API key included. A streaming call returns the saved response as updates once the model has finished, as Inngest does not stream the answer to the SDK. It speaks the OpenAI chat format only; for another provider's format, call `InferAsync` yourself. Strict structured outputs are asked for as Microsoft's adapter for the OpenAI client takes them: with a `strict` additional property, `true` or `false`, on the `ChatOptions` or on an `AIFunction`, which wins for that function. Without it, the request has no `strict` and the schemas of the response format and of the tools go out as Microsoft.Extensions.AI made them. With `true`, they are changed into what strict mode takes, which is no additional properties, every property required and no keywords OpenAI does not support, such as `minLength`, which move into the description, and the request says `"strict": true`. An answer that is an error, in an `error` object, or that has no choices fails the call with what the provider said, as a `NonRetriableException`: Inngest saved the answer with the run, so a retry would read the same one. A refusal comes back as an `ErrorContent` with the code `Refusal`, and goes out as `refusal` when the assistant's message is sent again. The mapping leaves out reasoning content, author names, data content that is not an image, and the additional properties of messages and content. Of the options it sends `ModelId`, `Instructions`, `Temperature`, `TopP`, `FrequencyPenalty`, `PresencePenalty`, `MaxOutputTokens`, `Seed`, `StopSequences`, `ResponseFormat`, `Tools`, `ToolMode` and `AllowMultipleToolCalls`, and no others. `MaxOutputTokens` goes out as `max_completion_tokens`, which the current models take, and not as `max_tokens`. ## Editing the input The Inngest UI can rerun a step with an edited input. For a model call, the edited `messages` and `options` are what the model is called with, and the tools and the request factory of the call are kept as the code passed them, as they are not JSON. For a tool call, the edited `arguments` are what the tool is called with. An edit that cannot be read, such as one that is not an object with `messages`, or with `arguments`, or that names a kind of content that does not exist, fails the step at once, as reading it again would fail the same way. Values in the `additionalProperties` of an edit that are booleans, numbers or strings are handed to the model as a `bool`, a `long` or `double`, and a `string`, as a provider tests for those, and objects and arrays stay `JsonElement`s. Without an edit, the model gets the messages and options the caller passed, not a copy read back from the recorded JSON, so what JSON cannot hold, such as the raw representations of a provider, still reaches it. ## What the input costs Inngest stores the input of a step as plain JSON, which the [encryption middleware](https://damikun.github.io/inngest-dotnet/guides/middleware.md#encryption) does not cover, and the messages of a conversation repeat in the input of every model call of a run. Where the prompts must not be stored in the clear, or the conversations grow too large for that, leave the input out: ```csharp using Microsoft.Extensions.AI; builder .UseInngestFunctionInvocation(configureSteps: options => options.RecordInput = false) .UseInngestSteps(options => options.RecordInput = false); ``` The steps are then plain run steps. The results are saved either way, and so are the model's answers and the tools' results, which is where the sensitive data usually is as well, and so is a digest of each input, which is a hash of it and not the input. A step whose input cannot be written as JSON, such as one with an additional property of a type the app has no contract for under Native AOT, runs without its input and logs a warning once. ## Testing The test host of [`Inngest.Testing`](https://damikun.github.io/inngest-dotnet/guides/testing.md) runs these steps like any others. The simplest test gives the pipeline a fake `IChatClient` as the model, runs the function and counts the calls: the model is called once per step however many times the host replays the function. A step can also be answered by id, with the JSON of a `ChatResponse`; the steps of `InngestInferChatClient` are answered with `StepMocks.Infer` and the provider's response: ```csharp using Inngest.Testing; using Microsoft.Extensions.AI; var answer = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Thank you for your order!")); var mocks = new StepMocks().Run("chat", JsonSerializer.SerializeToElement(answer, AIJsonUtilities.DefaultOptions)); var run = await host.RunAsync("send-note", @event, mocks, cancellationToken); ``` `run.GetStep("chat")` and `run.GetStep("tool get_weather")` show the input and output of each step. ## What it does not do - It wraps `IChatClient` only, and `InngestInferChatClient` is for the OpenAI chat format only. Other Microsoft.Extensions.AI clients, such as `IEmbeddingGenerator`, are not steps; wrap them with `RunAsync`. - The updates of a stream arrive after the model has finished, not as it writes; publish to a realtime topic for that. - Calls made inside a step handler are not steps of their own, so an agent loop run entirely inside one `RunAsync` is one step, with no tool steps. - Inngest's [limits](https://www.inngest.com/docs/durable-execution/limits) on the size of a step apply to the saved responses. --- # Concurrency, throttling and rate limits > Concurrency, throttling, rate limits, debounce, batching, priority, idempotency, singletons, cancellation and timeouts, set in FunctionOptions or with attributes and enforced by Inngest. Web page: https://damikun.github.io/inngest-dotnet/guides/flow-control/ Flow control decides when runs start and how many run at once. You set it per function, in its `FunctionOptions` or with attributes on its class, the SDK sends it to Inngest when the app syncs, and Inngest enforces it. Nothing in your app has to queue, lock or count. ```csharp title="SyncAccount.cs" using Inngest; public sealed record AccountUpdated(string AccountId) : IInngestEventData { public static string EventName => "account/updated"; } public sealed record AccountDeleted(string AccountId) : IInngestEventData { public static string EventName => "account/deleted"; } public sealed class SyncAccount : InngestFunction { public override Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) => Task.FromResult(null); } ``` Its options, with a concurrency limit, a throttle, a debounce and a cancellation event: ## Attributes ```csharp [InngestFunction("sync-account")] [EventTrigger] [Concurrency(5, Key = "event.data.accountId")] [Throttle(10, "1m")] [Debounce("30s", Key = "event.data.accountId")] [CancelOn(Match = "data.accountId")] ``` The attributes go on `SyncAccount`, and `inngest.AddFunction()` registers it without options. ## Code first ```csharp inngest.AddFunction(new FunctionOptions { Id = "sync-account", Triggers = [Trigger.Event()], Concurrency = [new ConcurrencyLimit(5) { Key = "event.data.accountId" }], Throttle = new Throttle(10, TimeSpan.FromMinutes(1)), Debounce = new Debounce(TimeSpan.FromSeconds(30)) { Key = "event.data.accountId" }, CancelOn = [CancelOn.For() with { Match = "data.accountId" }], }); ``` Each attribute sets the `FunctionOptions` member of the same name. Durations are `TimeSpan`s in code and time strings in attributes, such as `"30s"`, `"5m"` or `"1h30m"`, because attribute arguments must be constants. The sections below show both forms. The [function options builder](https://damikun.github.io/inngest-dotnet/explainers/function-options.md) shows the JSON the options turn into, and which record each attribute sets. ## See it on a timeline The timeline below sends the same burst of events to a function with each kind of flow control; change the settings to see which runs start at once, which wait, which are skipped and which are merged. The [flow control explainer](https://damikun.github.io/inngest-dotnet/explainers/flow-control.md) says how to read it. An interactive version is on [the web page](https://damikun.github.io/inngest-dotnet/guides/flow-control/). ## Keys Most options take a `Key`, an [expression](https://www.inngest.com/docs/guides/writing-expressions) evaluated against each event. Runs with the same key value share a limit, and runs with different values do not. A concurrency limit of 1 keyed on `event.data.accountId` runs one step at a time per account, while all accounts run side by side. A key can combine fields: `event.data.tenantId + "-" + event.data.region`. ## Concurrency ### Attributes ```csharp [Concurrency(50)] [Concurrency(1, Key = "event.data.accountId")] ``` ### Code first ```csharp Concurrency = [ new ConcurrencyLimit(50), new ConcurrencyLimit(1) { Key = "event.data.accountId" }, ], ``` Limits how many steps of the function execute at the same time. Runs over the limit wait in a queue and start in order. A function can have up to two limits; a step starts when it fits both. Concurrency counts executing steps, not runs: a run that sleeps or waits for an event holds no slot. A limit of 0 means no limit, as in Inngest. `Scope` widens a keyed limit beyond the function. `ConcurrencyScope.Environment` shares it with every function in the environment that uses the same key, and `ConcurrencyScope.Account` with every environment of the account. Both need a key. See [concurrency](https://www.inngest.com/docs/durable-execution/flow-control/concurrency). ## Throttle ### Attributes ```csharp [Throttle(10, "1m", Burst = 5, Key = "event.data.merchantId")] ``` ### Code first ```csharp Throttle = new Throttle(10, TimeSpan.FromMinutes(1)) { Burst = 5, Key = "event.data.merchantId" }, ``` Limits how many runs start per period. Runs over the limit are queued, not dropped, and their starts are spread over the period. `Burst` lets that many extra runs start at once. Use it to protect an API with a rate limit of its own. The period must be at least one second. See [throttling](https://www.inngest.com/docs/durable-execution/flow-control/throttling). ## Rate limit ### Attributes ```csharp [RateLimit(1, "4h", Key = "event.data.userId")] ``` ### Code first ```csharp RateLimit = new RateLimit(1, TimeSpan.FromHours(4)) { Key = "event.data.userId" }, ``` Also limits how many runs start per period, but events over the limit are skipped and never run. Use it where running late is worse than not running, such as a "your report is ready" email. The period is between one second and 24 hours. See [rate limiting](https://www.inngest.com/docs/durable-execution/flow-control/rate-limiting). ## Debounce ### Attributes ```csharp [Debounce("30s", Key = "event.data.productId", Timeout = "5m")] ``` ### Code first ```csharp Debounce = new Debounce(TimeSpan.FromSeconds(30)) { Key = "event.data.productId", Timeout = TimeSpan.FromMinutes(5) }, ``` Waits until events stop arriving for the period, then runs once with the last event. Each new event restarts the period. `Timeout` caps the total wait, so a steady stream of events still runs the function every so often. The period is between one second and seven days. See [debounce](https://www.inngest.com/docs/durable-execution/flow-control/debounce). ## Batching ### Attributes ```csharp [BatchEvents(100, "10s", Key = "event.data.importId")] ``` ### Code first ```csharp BatchEvents = new BatchEvents(100, TimeSpan.FromSeconds(10)) { Key = "event.data.importId" }, ``` Runs the function with up to `MaxSize` events at once, or with what has arrived when the timeout passes. The events are in `context.Events`; `context.Event` is the first of them. `If` limits batching to events that match an expression; others run on their own. ```csharp var batch = context.Events.Select(e => e.Data).ToList(); await context.Step.RunAsync("write-rows", ct => rows.WriteAsync(batch, ct)); ``` `MaxSize` must be at least 2 and the timeout at least one second; how long it can be depends on your Inngest plan, which the SDK leaves to Inngest. Inngest does not allow batching together with cancellation, debounce, singletons or priority, and the SDK rejects those combinations at startup. See [batching](https://www.inngest.com/docs/durable-execution/flow-control/batching). ## Priority ### Attributes ```csharp [Priority("event.data.plan == 'enterprise' ? 120 : 0")] ``` ### Code first ```csharp Priority = new Priority("event.data.plan == 'enterprise' ? 120 : 0"), ``` `Run` is an expression that returns a number of seconds, from -600 to 600. A run with priority 120 starts ahead of runs that have been waiting for up to two minutes; a negative value holds a run back. Priority only matters when runs queue up, so it goes together with concurrency or throttling. See [priority](https://www.inngest.com/docs/durable-execution/flow-control/priority). ## Idempotency ### Attributes ```csharp [InngestFunction("record-payment", Idempotency = "event.data.paymentId")] ``` ### Code first ```csharp Idempotency = "event.data.paymentId", ``` Starts at most one run per value of the expression in 24 hours, for this function. Other functions triggered by the same event are not affected. Inngest implements it as a rate limit of 1 per 24 hours, so it replaces any `RateLimit`. To deduplicate an event for every function, give the event an [`Id`](https://damikun.github.io/inngest-dotnet/guides/sending-events.md#deduplicating-with-an-id) instead. See [idempotency](https://www.inngest.com/docs/durable-execution/guides-and-advanced/idempotency). ## Singleton ### Attributes ```csharp [Singleton(SingletonMode.Skip, Key = "event.data.accountId")] ``` ### Code first ```csharp Singleton = new Singleton(SingletonMode.Skip) { Key = "event.data.accountId" }, ``` Allows one active run per key. With `SingletonMode.Skip` a new run is skipped while another is active; with `SingletonMode.Cancel` the new run cancels the active one. Unlike a concurrency limit of 1, this covers the whole run, sleeps and waits included. See [singleton](https://www.inngest.com/docs/durable-execution/flow-control/singleton). ## Cancel on events ### Attributes ```csharp [CancelOn(Match = "data.accountId")] [CancelOn("subscription/changed", If = "async.data.plan == 'free'", Timeout = "7d")] ``` ### Code first ```csharp CancelOn = [ CancelOn.For() with { Match = "data.accountId" }, new CancelOn("subscription/changed") { If = "async.data.plan == 'free'", Timeout = TimeSpan.FromDays(7) }, ], ``` Cancels a run when a matching event arrives. `Match` compares a property of the triggering event with the same property of the cancelling one; `If` is a full expression where `event` is the triggering event and `async` the cancelling one; when both are set, both apply. `CancelOn.For()` and `[CancelOn]` take the event's name from a [typed event](https://damikun.github.io/inngest-dotnet/guides/sending-events.md#typed-events). `Timeout` limits how long after the run is scheduled an event can still cancel it. A function can have up to five. Cancellation takes effect between steps: a step that is running finishes, and the run stops before the next one. See [cancellation](https://www.inngest.com/docs/durable-execution/guides-and-advanced/cancellation/events). ## Timeouts ### Attributes ```csharp [Timeouts(Start = "10m", Finish = "1h")] ``` ### Code first ```csharp Timeouts = new FunctionTimeouts { Start = TimeSpan.FromMinutes(10), Finish = TimeSpan.FromHours(1) }, ``` `Start` cancels a run that waited in the queue longer than that before its first step. `Finish` cancels a run that takes longer than that from its first step to the end. Neither interrupts a step that is running; give the calls inside your steps timeouts of their own. See [timeouts](https://www.inngest.com/docs/durable-execution/guides-and-advanced/cancellation/timeouts). ## Validation The SDK checks these options when the app starts, against the limits Inngest enforces, whether they come from code or attributes. It throws one `InvalidOperationException` that lists every problem of every function, each naming its function, for example `Function "import-rows": BatchEvents cannot be combined with Debounce.` A duration in an attribute that is not a time string fails as well, when `AddFunction` reads it. A mistake fails the deploy instead of the sync. The [flow control overview](https://www.inngest.com/docs/durable-execution/flow-control) in the Inngest docs explains how the options interact and in which order Inngest applies them. ## Related examples The [webhook pipeline](https://damikun.github.io/inngest-dotnet/examples/webhook-pipeline.md) uses idempotency and a throttle, [nightly billing](https://damikun.github.io/inngest-dotnet/examples/nightly-billing.md) concurrency and a singleton, and [order fulfillment](https://damikun.github.io/inngest-dotnet/examples/order-fulfillment.md) cancellation. --- # Errors, retries and failure handlers > How failed steps and functions are retried, how to stop or delay retries with exceptions, how to recover from a failed step, and how to run code when a function fails for good. Web page: https://damikun.github.io/inngest-dotnet/guides/errors-and-retries/ Errors are exceptions. When a step throws, Inngest retries that step. When code outside steps throws, Inngest retries the call, which runs the function from the top again with the steps that already succeeded memoized. Three exception types change that default, and a failure handler runs when everything else has failed. ## How many retries `Retries` on `[InngestFunction]`, or in `FunctionOptions`, is the number of retries after the first attempt, from 0 to 20. Without it, Inngest retries 4 times, so 5 attempts in all. ### Attributes ```csharp [InngestFunction("import-catalog", Retries = 8)] ``` ### Code first ```csharp Id = "import-catalog", Retries = 8, ``` The count applies to each step separately, not to the run as a whole: with `Retries = 8`, every step gets up to 9 attempts. Inngest waits longer between attempts as they go on. `context.Attempt` is the zero-based attempt of the current step or call, and `context.MaxAttempts` the total, when Inngest sends it. See [retries](https://www.inngest.com/docs/durable-execution/guides-and-advanced/error-handling/retries) in the Inngest docs. A retry can repeat work that succeeded, when the call that did it is lost before Inngest records the result, so a step that writes to a database or calls a service should be safe to run twice. [Scoped steps](https://damikun.github.io/inngest-dotnet/guides/scoped-steps.md#idempotency-keys) shows how for Entity Framework Core. ## Fail without retrying Throw `NonRetriableException` when another attempt cannot help, such as for invalid input: ```csharp var order = context.Event.Data; if (order.Total <= 0) { throw new NonRetriableException($"Order {order.OrderId} has no amount to charge."); } await context.Step.RunAsync("charge", ct => payments.ChargeAsync(order.CustomerId, order.Total, ct)); ``` Thrown outside a step, it fails the run at once. Thrown inside a step's handler, it fails that step at once, and the function gets a `StepFailedException` where it awaits the step, as described below. ## Retry later Throw `RetryAfterException` to retry, but not before a delay or a point in time. It suits APIs that say when to come back: ```csharp var rates = await context.Step.RunAsync("fetch-rates", async ct => { using var response = await http.GetAsync("https://rates.example.com/latest", ct); if (response.StatusCode is System.Net.HttpStatusCode.TooManyRequests) { var wait = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromMinutes(1); throw new RetryAfterException("The rates API asked us to slow down.", wait); } response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(ct); }); ``` The SDK sends the delay to Inngest as a `Retry-After` header. Inngest waits at least a second and at most a day; a longer delay is cut to a day. ## Recover from a failed step When a step has failed all its attempts, the SDK throws `StepFailedException` where the function awaits the step, on that call and on every later replay. Catch it to recover, for example to undo earlier steps: ```csharp var order = context.Event.Data; var reservationId = await context.Step.RunAsync("reserve-stock", ct => inventory.ReserveAsync(order.OrderId, ct)); try { await context.Step.RunAsync("charge-card", ct => payments.ChargeAsync(order.CustomerId, order.Total, ct)); } catch (StepFailedException exception) { await context.Step.RunAsync("release-stock", ct => inventory.ReleaseAsync(reservationId, ct)); throw new NonRetriableException($"Payment failed: {exception.Error.Message}", exception); } ``` `StepId` names the failed step and `Error` holds the original failure as Inngest stored it: the exception type name, the message and the stack trace. The exception object itself does not survive; the step failed in another call, perhaps on another machine. Left uncaught, `StepFailedException` fails the run without further retries: the step already used its attempts. Inngest's guide to [rollbacks](https://www.inngest.com/docs/durable-execution/guides-and-advanced/error-handling/rollbacks) covers compensation in more depth. ## Run code when a function fails Implement `IFailureHandler` on the function's class to run code once the function has failed for good: ```csharp title="ImportCatalog.cs" using Inngest; public sealed record CatalogUploaded(string FileId); public sealed class ImportCatalog(ICatalogImporter importer, IAlerts alerts) : InngestFunction, IFailureHandler { public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) { var count = await context.Step.RunAsync("import", ct => importer.ImportAsync(context.Event.Data.FileId, ct)); return new { count }; } public async Task OnFailureAsync(FunctionContext context, CancellationToken cancellationToken) { var failure = context.Event.Data; await context.Step.RunAsync("alert", ct => alerts.SendAsync($"Catalog import {failure.RunId} failed: {failure.Error.Message}", ct)); } } public interface ICatalogImporter { Task ImportAsync(string fileId, CancellationToken cancellationToken); } public interface IAlerts { Task SendAsync(string text, CancellationToken cancellationToken); } ``` The handler is a function of its own. The SDK registers it as `-failure`, triggered by the `inngest/function.failed` event for this function, with one retry, and it can use steps like any function. `context.Event.Data` is a `FunctionFailure`: | Property | Meaning | | - | - | | `FunctionId` | The failed function as Inngest knows it, `-`. | | `RunId` | The failed run. `context.RunId` is the failure handler's own run. | | `Error` | The error the run failed with: `Name`, `Message`, `Stack` and `Cause`, the error that caused it. | | `Event` | The event that triggered the failed run, as a `JsonElement`. | With [`Inngest.UserContext`](https://damikun.github.io/inngest-dotnet/guides/user-context.md#pass-the-user-on) the handler also runs as the user who caused the failed run: `ICurrentUser` is restored from `Event`. A delegate function takes its failure handler as `onFailure`: ```csharp inngest.AddFunction( new FunctionOptions { Id = "import-catalog", Triggers = [Trigger.Event("catalog/uploaded")] }, async (context, cancellationToken) => { var count = await context.Step.RunAsync("import", ct => importer.ImportAsync(context.Event.Data.FileId, ct)); return new { count }; }, onFailure: (context, cancellationToken) => { context.Logger.LogError("Run {RunId} failed: {Message}", context.Event.Data.RunId, context.Event.Data.Error.Message); return Task.CompletedTask; }); ``` To handle failures of every function in one place, write a function triggered by `inngest/function.failed` itself, with `FunctionFailure` as its event data. See Inngest's [failure handlers](https://www.inngest.com/docs/durable-execution/guides-and-advanced/error-handling/failure-handlers) page and the [function.failed event](https://www.inngest.com/docs/reference/system-events/inngest-function-failed). ## What Inngest records The SDK reports an error as the exception's type name, its message and its stack trace, and includes inner exceptions as the cause, up to five levels deep. That is what the run history shows, what `StepFailedException.Error` and `FunctionFailure.Error` hold, and what Inngest sends in `inngest/function.failed`. A run that is cancelled, by an event, a timeout or the API, ends with an `inngest/function.cancelled` event. A function triggered by that event can clean up after it; see [function.cancelled](https://www.inngest.com/docs/reference/system-events/inngest-function-cancelled). Middleware sees every failed attempt, not only the last one, through `OnRunErrorAsync` and `OnStepErrorAsync`; see [Middleware](https://damikun.github.io/inngest-dotnet/guides/middleware.md). ## Related examples [Order fulfillment](https://damikun.github.io/inngest-dotnet/examples/order-fulfillment.md) compensates a failed payment, and the [webhook pipeline](https://damikun.github.io/inngest-dotnet/examples/webhook-pipeline.md) retries with `RetryAfterException`. --- # Middleware for Inngest functions > Observe and wrap function runs, steps and event sends with InngestMiddleware, for logging, metrics, tracing, error mapping, event enrichment, encryption and reporting errors to Sentry. Web page: https://damikun.github.io/inngest-dotnet/guides/middleware/ Middleware runs code around every function call, step and event send of the app, without touching the functions themselves. Derive from `InngestMiddleware`, override the hooks you need and register the class. ```csharp title="TimingMiddleware.cs" using System.Diagnostics; using Inngest; public sealed class TimingMiddleware(ILogger logger) : InngestMiddleware { public override async Task WrapStepHandlerAsync(FunctionContext context, StepInfo step, Func> next) { var started = Stopwatch.GetTimestamp(); try { return await next(); } finally { logger.LogInformation("Step {Step} of {Function} took {Elapsed}", step.Id, context.FunctionId, Stopwatch.GetElapsedTime(started)); } } } ``` ```csharp title="Program.cs" builder.Services .AddInngest(options => options.AppId = "shop") .AddMiddleware(); ``` ## Registration and lifetime `AddMiddleware()` registers the class as a scoped service. One instance serves one call from Inngest, including the events its steps send, so it can take scoped dependencies and keep state for the call in fields. `AddMiddleware(instance)` registers one instance for every call instead; keep such an instance free of per-call state. `context.Services` is the call's scope for every hook. A [scoped step](https://damikun.github.io/inngest-dotnet/guides/scoped-steps.md) runs its handler in a scope of its own that middleware does not see: `WrapStepHandlerAsync` wraps the handler together with that scope, which exists only inside `next()`. Middleware runs in registration order. For wrapping hooks, the first one registered is the outermost. Middleware for one function goes in its `FunctionOptions.Middleware` instead. It runs inside the app's middleware, for the function's failure handler too. `FunctionMiddleware.Of()` resolves the class from the call's scope, and registers it as scoped unless the app registered it; `FunctionMiddleware.Of(factory)` calls a factory with the call's scope on every call. ## Observing hooks These run at points of a call and return nothing. An exception thrown in one is logged and the other middleware still run. | Hook | When it runs | | - | - | | `OnRunStartAsync(context)` | On the first call of a run only: the first attempt, with no memoized steps. | | `OnMemoizationEndAsync(context)` | Once the memoized steps have been replayed and the function reaches new code. | | `OnRunCompleteAsync(context, output)` | When the function returns. | | `OnRunErrorAsync(context, exception, isFinalAttempt)` | Every time the function throws; `isFinalAttempt` is true when no retry follows. | | `OnStepStartAsync(context, step)` | Before a step handler runs. Memoized steps do not run and raise no step hooks. | | `OnStepCompleteAsync(context, step, output)` | After a step handler succeeds. | | `OnStepErrorAsync(context, step, exception, isFinalAttempt)` | After a step handler throws. | Steps cannot be called from these hooks; the SDK throws an `InvalidOperationException` if one tries. ## Wrapping hooks These receive a `next` delegate and decide what happens around it. Exceptions they throw propagate. | Hook | What it wraps | | - | - | | `WrapFunctionAsync(context, next)` | The function body, on every call, replays included. | | `WrapStepAsync(context, step, next)` | How a step's result reaches the function, memoized or freshly run. The result can be replaced. | | `WrapStepHandlerAsync(context, step, next)` | Only the actual execution of a step handler, once per attempt. | | `WrapSendEventAsync(context, events, next, cancellationToken)` | Every event send, from `IInngestClient` or a step. Pass changed events to `next` to change what is sent. `context` is `null` outside a run. | `StepInfo` describes the step: `Id` as the code wrote it, with the `:n` suffix of a repeated id; `HashedId`, which is what Inngest stores; `Type`; `Memoized`; and `Name`, the display name the code gave the step, if any. ### When next never returns A call that reaches a new step ends there: the SDK reports the step to Inngest and abandons the rest of the function. In such a call, the `next()` of `WrapFunctionAsync` never completes, and neither does the `next()` of `WrapStepAsync` for the new step. Code after `await next()`, and `finally` blocks around it, do not run in that call. Put cleanup that must happen in `WrapStepHandlerAsync`, whose `next()` always completes, or in the observing hooks. Middleware can call step tools from `WrapFunctionAsync` and `WrapStepAsync`. A step created inside a middleware's `WrapStepAsync` is not wrapped by that same middleware again. Memoized results go back to the function in the order the steps finished, which is how a race replays the way it first went. A `WrapStepAsync` that awaits real I/O for a memoized step, such as a call to a key vault, lets the next result overtake it, and then a `Task.WhenAny` can pick a different winner than the first time. Keep that work out of the memoized path, or rewrite payloads in a payload transform, which runs synchronously. ## Examples ### Map errors to non-retriable ones A `404` from an API will not go away on retry. Turn it into a failure that stops at once: ```csharp title="NotFoundIsFinalMiddleware.cs" using System.Net; using Inngest; public sealed class NotFoundIsFinalMiddleware : InngestMiddleware { public override async Task WrapStepHandlerAsync(FunctionContext context, StepInfo step, Func> next) { try { return await next(); } catch (HttpRequestException exception) when (exception.StatusCode == HttpStatusCode.NotFound) { throw new NonRetriableException($"Step {step.Id} got a 404.", exception); } } } ``` ### Stamp every event `InngestEvent` is a record, so a middleware can change events with `with` before they are sent: ```csharp title="EventVersionMiddleware.cs" using Inngest; public sealed class EventVersionMiddleware : InngestMiddleware { public override Task WrapSendEventAsync( FunctionContext? context, IReadOnlyList events, Func, Task> next, CancellationToken cancellationToken) => next([.. events.Select(e => e.Version is null ? e with { Version = "2026-10" } : e)]); } ``` Attaching the signed-in user to every event is a change of this kind. [`Inngest.UserContext`](https://damikun.github.io/inngest-dotnet/guides/user-context.md) does it with the claims protected, and restores them in the run. ### Count failures Observing hooks suit metrics. This one counts final failures per function with `System.Diagnostics.Metrics`; the instruments are static because the middleware is created for every call: ```csharp title="FailureMetricsMiddleware.cs" using System.Diagnostics.Metrics; using Inngest; public sealed class FailureMetricsMiddleware : InngestMiddleware { private static readonly Meter Meter = new("Shop.Inngest"); private static readonly Counter Failures = Meter.CreateCounter("inngest.run.failures"); public override ValueTask OnRunErrorAsync(FunctionContext context, Exception exception, bool isFinalAttempt) { if (isFinalAttempt) { Failures.Add(1, new KeyValuePair("function", context.FunctionId)); } return ValueTask.CompletedTask; } } ``` The [tracing example](https://damikun.github.io/inngest-dotnet/examples/tracing-middleware.md) wraps steps in OpenTelemetry activities. ## Encryption The `Inngest.Encryption` package encrypts event data, step results and function output in your app, so that Inngest stores and shows ciphertext for them. It writes the format of [`@inngest/middleware-encryption`](https://www.npmjs.com/package/@inngest/middleware-encryption), so .NET and TypeScript services can read each other's events, step results and output. ```bash dotnet add package Inngest.Encryption ``` ```csharp title="Program.cs" builder.Services .AddInngest(options => options.AppId = "shop") .AddEncryption(options => options.Key = builder.Configuration["Inngest:EncryptionKey"]); ``` The middleware covers every function of the app. Functions keep working with plain values: they receive events and memoized results decrypted, and what they return is encrypted on its way to Inngest. ### What is encrypted | Data | What is encrypted | | - | - | | The result of a step | All of it, unless it is `null`. | | The output of the function | All of it, unless it is `null`. | | The data of an event the app sends, from `IInngestClient` or a step | One top-level field of it: `encrypted`, or the field that `EventEncryptionField` names. | The field is matched against the name the serializer writes, so with the default camelCase options the property `Encrypted` is the field `encrypted`: ```csharp title="CardPayment.cs" using Inngest; public sealed record CardDetails(string Number, string Holder); public sealed record CardPayment(string OrderId, CardDetails Encrypted) : IInngestEventData { public static string EventName => "shop/card.paid"; } ``` ```csharp await inngest.SendAsync(new CardPayment("o-7", new CardDetails("4242 4242 4242 4242", "Ada Lovelace"))); ``` Inngest stores `OrderId` as it is and `Encrypted` as ciphertext. A function triggered by the event gets `context.Event.Data.Encrypted` as a `CardDetails`. This also works across languages: when an event arrives, every top-level field of its data that holds an encrypted value is decrypted, whatever it is called, and the same goes for the event a `WaitForEventAsync` matched. `context.RawEvent` stays as Inngest sent it. Everything else is sent as it is: event names, ids and timestamps, the other fields of the data, `User`, step ids and names, and the data passed to `InvokeAsync`. Inngest also stores the messages of exceptions as they are, so keep secrets out of them. It evaluates expressions, such as trigger conditions, concurrency keys and the `If` of a wait, against event data, so they can only use fields that are not encrypted. ### Keys A key is any text. The middleware hashes it with BLAKE2b to the 32 bytes the cipher needs, so its length does not matter but its randomness does: use a long random value, such as the output of `openssl rand -base64 32`, kept in a secret store, and not a password. White space around the key is ignored. Every service that reads the data needs the same key. ### Rotating keys `Key` always encrypts. `FallbackDecryptionKeys` only decrypt: they are tried when `Key` does not decrypt a value, and blank entries are ignored. To replace a key without failing the runs that hold data written with the old one: 1. Deploy every service with the new key as a fallback: `Key` is the current key and `FallbackDecryptionKeys` the new one. 2. Deploy again with the new key first, and the current key as the fallback. 3. Once no run holds data written with the current key, remove it. ```csharp title="Program.cs, step 2" builder.Services .AddInngest(options => options.AppId = "shop") .AddEncryption(options => { options.Key = newKey; options.FallbackDecryptionKeys = [currentKey]; }); ``` A value that no key decrypts throws a `CryptographicException` where it is read, which fails the call, and Inngest retries it. During a rolling deploy the retry can reach an instance that has the key. `DecryptOnly = true` makes a service read encrypted data and write none. Use it to add the middleware to every service before encryption is switched on in any of them, so that none receives data it cannot read, and to phase encryption out again. ### TypeScript services An encrypted value is the object `{"__ENCRYPTED__":true,"__STRATEGY__":"inngest/libsodium","data":"..."}`. `data` is the standard base64 of a random 24-byte nonce followed by what libsodium's `crypto_secretbox_easy` (XSalsa20-Poly1305) makes of the JSON text of the value, with the BLAKE2b hash of the key as its key. The package computes this with the managed primitives of BouncyCastle, which is its only dependency besides `Inngest`. The two sides need the same key, and the same event field for the events they send each other. In TypeScript the middleware is added to the client: ```ts import { Inngest } from "inngest"; import { encryptionMiddleware } from "@inngest/middleware-encryption"; const inngest = new Inngest({ id: "shop", middleware: [encryptionMiddleware({ key: process.env.ENCRYPTION_KEY! })], }); ``` Where the two differ: - The AES values, those of the first versions of the TypeScript package, which have no strategy, and those of its `AESEncryptionService`, are read and never written. They have no authentication, so a value counts as genuine when it decrypts to JSON. There is no counterpart of `legacyV0Service`. - The data of a step that invokes another function is not encrypted in .NET, because the payload hooks do not cover it. A .NET function invoked from TypeScript still reads the data decrypted. - The TypeScript package returns the event that `waitForEvent` matched with its field still encrypted. .NET decrypts it. ### Another cipher `EncryptionOptions.Service` replaces the default strategy for writing values. A service gets the UTF-8 JSON of a value and returns the text for the `data` field of the envelope, and the other way round. Its `Identifier` is written to `__STRATEGY__`, and picks the service that reads a value, so other services need the same identifier and algorithm. ```csharp title="AesGcmEncryptionService.cs" using System.Security.Cryptography; using Inngest; public sealed class AesGcmEncryptionService(byte[] key) : EncryptionService { private const int NonceSize = 12; private const int TagSize = 16; public override string Identifier => "shop/aes-gcm"; public override string Encrypt(ReadOnlySpan json) { var data = new byte[NonceSize + TagSize + json.Length]; RandomNumberGenerator.Fill(data.AsSpan(0, NonceSize)); using var aes = new AesGcm(key, TagSize); aes.Encrypt(data.AsSpan(0, NonceSize), json, data.AsSpan(NonceSize + TagSize), data.AsSpan(NonceSize, TagSize)); return Convert.ToBase64String(data); } public override byte[] Decrypt(string data) { var bytes = Convert.FromBase64String(data); var json = new byte[bytes.Length - NonceSize - TagSize]; using var aes = new AesGcm(key, TagSize); aes.Decrypt(bytes.AsSpan(0, NonceSize), bytes.AsSpan(NonceSize + TagSize), bytes.AsSpan(NonceSize, TagSize), json); return json; } } ``` ```csharp title="Program.cs" builder.Services .AddInngest(options => options.AppId = "shop") .AddEncryption(options => options.Service = new AesGcmEncryptionService(key)); ``` One instance serves every call at the same time, so it must be thread-safe, and it runs synchronously on the call's path: load keys at startup rather than from a vault on each value. `Key` is not needed with a service. When it is set, values that the default strategies wrote are still decrypted, so data can move from one to the other. ### Notes - Events pass through middleware in registration order, so register encryption after middleware that add or change events, or it does not see them. - A middleware that rewrites payloads with `IPayloadTransform` sees plain data if it is registered after encryption and ciphertext if it is registered before it. - A value grows by a third and by about 120 bytes of envelope, which counts against the size limits Inngest puts on events and step results. - The input of an AI wrap step and the request of an inference step are stored in the clear: the payload hooks cover what a step returns, not what it was made with. That includes the messages and options, and the arguments of tool calls, that the [chat client steps](https://damikun.github.io/inngest-dotnet/guides/ai.md#what-the-input-costs) record, which `InngestStepsOptions.RecordInput` turns off. The results of those steps are encrypted like any other. - It works under Native AOT. Event data types go in the serializer context as for any event. - The test host reports what is stored, so `Output` and `SentEvents` show encrypted values when encryption is added to the services it runs. Tests of functions usually leave it out. ## Sentry The [`Inngest.Sentry`](https://www.nuget.org/packages/Inngest.Sentry/) package reports the errors of functions and steps to Sentry. An exception that a function throws never leaves the request: the SDK turns it into the answer to Inngest, so Sentry's ASP.NET Core middleware never sees it. This middleware reports it, with the app, the function, the run and the event as tags. ```bash dotnet add package Inngest.Sentry ``` Set Sentry up as usual, and add the middleware before the others, so that their work falls inside its scope. It reports through the `IHub` the app registered, which `UseSentry` does, and otherwise through the static hub. While Sentry is not enabled it does nothing. ```csharp title="Program.cs" builder.WebHost.UseSentry(options => options.Dsn = "https://key@o0.ingest.sentry.io/0"); builder.Services .AddInngest(options => options.AppId = "shop") .AddSentryReporting(); ``` ### What it reports The middleware reports the exception of a function when the function throws, and the exception of a step when its handler throws and `CaptureStepErrors` is on. By default it reports only the final attempt, after which Inngest does not retry: the retries are used up, or the error is a `NonRetriableException`. Each step the call executed leaves a breadcrumb for the events that follow, in the category `inngest.step`, with the level `Error` for a step that failed. An event it reports has the transaction name `inngest:` and these tags: | Tag | Value | | - | - | | `inngest.client.id` | The app id. | | `inngest.function.id` | The function id with the app id in front, as Inngest names it: `shop-checkout`. | | `inngest.run.id` | The run id. | | `inngest.attempt` | The attempt, counted from `0`. | | `inngest.event.name`, `inngest.event.id` | The triggering event, or the first event of a batch. | | `inngest.error.source` | `run` for the error of a function, `step` for the error of a step. | | `inngest.step.name` | For the error of a step, its display name, or its id when it has none. | A step that fails for good and reaches a function that does not catch it is reported by the function. With `CaptureStepErrors` on, the step reports it as well, so it appears twice. ### Scope and trace Sentry's scope follows the async flow. The middleware pushes one around the function and around every step handler, with the tags of the call, so that what your code reports there carries them too: a `SentrySdk.CaptureException` in a step, or an error logged through Sentry's logging integration. Sentry.AspNetCore locks the scope of a request, so a push there creates nothing, and the tags go on the request's scope, which only this call uses. In Sentry's global mode, which is meant for desktop and mobile apps, the whole app has one scope and a push creates nothing either, so the tags go on it, and calls that run at the same time mix them. All calls of a run report to one trace. A run id is a ULID, which holds 128 bits, as a trace id does, and the middleware makes it the trace id the way the TypeScript middleware does. The events it reports are in that trace, even when the calls reach different instances of the app, and not in the trace of the request that carries the call, which is new for every call. What your own code reports during the call stays in the request's trace while Sentry has a transaction for the request, as Sentry.AspNetCore starts one. ### Options | Option | Default | Effect | | - | - | - | | `OnlyCaptureFinalAttempt` | `true` | Reports an error only on the final attempt. When `false`, every attempt is reported. | | `CaptureStepErrors` | `false` | Reports the error of a step as an event of its own, besides the error of the function. | | `DisableAutomaticFlush` | `false` | Leaves flushing to Sentry. | | `FlushTimeout` | 2 seconds | How long the flush waits for events to be sent, which is Sentry's own default. | ```csharp title="Program.cs" builder.Services .AddInngest(options => options.AppId = "shop") .AddSentryReporting(options => { options.OnlyCaptureFinalAttempt = false; options.CaptureStepErrors = true; }); ``` Unless `DisableAutomaticFlush` is set, the middleware flushes Sentry when the call's service scope ends, so that events are sent before a runtime that stops once a call has answered, as serverless ones may, goes away. ### Sentry's logging integration `UseSentry` also turns on Sentry's logging integration, which reports log entries of the level `Error` and above as events. When a function or a step fails for good, the SDK writes such an entry with the exception: event id 107 for a function and 109 for a step. It comes before the middleware's report, and Sentry keeps one event per exception, so the event is the log entry's. In an ASP.NET Core app it has the tags of the call, but not the error source or the transaction name, and the options above do not apply to it. To have the middleware alone report these errors, filter the entries out: ```csharp title="Program.cs" using Sentry.Extensions.Logging; builder.WebHost.UseSentry(options => options.AddLogEntryFilter((category, level, eventId, exception) => category.StartsWith("Inngest.", StringComparison.Ordinal) && eventId.Id is 107 or 109)); ``` ### Compared with @inngest/middleware-sentry - The TypeScript middleware also starts a Sentry span for each request and each step. This one reports errors only. For spans, use [`Inngest.OpenTelemetry`](https://damikun.github.io/inngest-dotnet/reference/api.md#opentelemetry) with Sentry's OpenTelemetry integration. - There is no `ctx.sentry`. Use `SentrySdk`, or take an `IHub` from the container. - There is no `inngest.function.name` tag, because a function's context holds its id only. - `FlushTimeout` is new: `FlushAsync` needs a timeout. ## Compared with the TypeScript SDK The hooks follow inngest-js v4's middleware, with .NET names. The TypeScript SDK's transform hooks (`transformFunctionInput`, `transformStepInput`) have a .NET counterpart in `IPayloadTransform`: a middleware that implements it rewrites the raw JSON of events, step results, step output and the function's output, which is what [encryption](https://damikun.github.io/inngest-dotnet/guides/middleware.md#encryption) needs. There is no `wrapRequest`: ASP.NET Core middleware or an endpoint filter on `MapInngest` wraps a request, and constructor injection covers most other uses. Inngest's [middleware guide](https://www.inngest.com/docs/durable-execution/guides-and-advanced/middleware) describes the lifecycle in general. --- # Configuration and environment variables > Every InngestOptions setting, the INNGEST_* environment variables they fall back to, dev and cloud mode, and settings for proxies, branch environments and self-hosted Inngest. Web page: https://damikun.github.io/inngest-dotnet/guides/configuration/ `AddInngest` takes a delegate that sets `InngestOptions`. Anything left unset is read from configuration, so the environment variables Inngest's other SDKs use work here too. Options set in code win. ```csharp builder.Services.AddInngest(options => { options.AppId = "shop"; options.AppVersion = "2026.10.3"; }); ``` `AppId` is the only required setting. The SDK checks it when the host starts. ## Settings | Option | Variable | Default | | - | - | - | | `AppId` | | required | | `AppVersion` | | none | | `IsDev`, `DevServerUrl` | `INNGEST_DEV` | cloud mode; the dev server at `http://localhost:8288` | | `EventKey` | `INNGEST_EVENT_KEY` | none; not needed in dev mode | | `SigningKey` | `INNGEST_SIGNING_KEY` | none; required in cloud mode | | `SigningKeyFallback` | `INNGEST_SIGNING_KEY_FALLBACK` | none | | `Environment` | `INNGEST_ENV` | the signing key's environment | | `BaseUrl` | `INNGEST_BASE_URL` | | | `ApiBaseUrl` | `INNGEST_API_BASE_URL` | `https://api.inngest.com` | | `EventApiBaseUrl` | `INNGEST_EVENT_API_BASE_URL` | `https://inn.gs` | | `ServeOrigin` | `INNGEST_SERVE_ORIGIN` | the origin the request arrived on | | `ServePath` | `INNGEST_SERVE_PATH` | the path the request arrived on | | `AllowInBandSync` | `INNGEST_ALLOW_IN_BAND_SYNC` | `true` | | `AllowUnsignedSync` | `INNGEST_ENABLE_UNAUTHED_SYNC` | `true` | | `Streaming` | `INNGEST_STREAMING` | `false` | | `SessionPropagation` | `INNGEST_SESSION_PROPAGATION` | `true` | | `SerializerOptions` | | `JsonSerializerDefaults.Web` | An address in a variable may leave out the scheme, as in `INNGEST_DEV=localhost:8288`. The dev server, localhost, IP addresses, names without dots (such as a container's) and names ending in `.internal`, `.local` or another private suffix get `http`. Any other host gets `https`, so a key never crosses the internet in clear text by mistake. The SDK reads the variables through `IConfiguration` when the host has one, so they can also come from `appsettings.json`, user secrets, Azure App Configuration or any other configuration source, under the same names: ```json title="appsettings.Development.json" { "INNGEST_DEV": "1" } ``` Without `IConfiguration`, for example in a plain `ServiceCollection`, it reads the process environment. ## Settings in appsettings.json `AddInngest` can also read the options from a configuration section, so that they sit in `appsettings.json` with the rest of the app's settings: ```json title="appsettings.json" { "Inngest": { "AppId": "billing", "EventKey": "...", "SigningKey": "..." } } ``` ```csharp builder.Services.AddInngest(builder.Configuration.GetSection("Inngest")); ``` Every option in the table above has a key of the same name, except `SerializerOptions`. `Checkpointing` is a subsection with the keys `Enabled`, `MaxRuntime`, `BufferedSteps` and `MaxInterval`. A duration is an Inngest time string such as `30s`, `5m` or `1h30m`, or the form .NET writes a `TimeSpan` in, such as `00:00:30`. Keys the SDK does not know are ignored. A value it cannot read stops the app with an error that names the key, such as `Inngest:IsDev must be true or false, but it is 'maybe'.` The section is the lowest layer. A setting comes from the first of these that has it: 1. Options set in code, in the callback of `AddInngest` or in `Configure`. 2. The `INNGEST_*` variables. 3. The section. 4. The default. So a container can still set `INNGEST_DEV` or `INNGEST_SIGNING_KEY` to override the file, and `AddInngest` and `Configure` can be called in any order. `AppId` may come from the section too; the startup check accepts it from either place. The SDK reads the keys one by one instead of binding the section to `InngestOptions`, so this works in trimmed and Native AOT apps. Keep the keys out of the file you commit: user secrets, or environment variables such as `Inngest__SigningKey`, reach the section like any other configuration source. ## Dev mode and cloud mode The SDK runs in one of two modes: - **Cloud mode** is the default. Requests from Inngest must be signed with the signing key, and the SDK signs its answers. Events go to Inngest Cloud with the event key. Use it in every deployed environment. - **Dev mode** talks to a local [dev server](https://www.inngest.com/docs/local-development), checks no signatures and needs no keys. `INNGEST_DEV` selects the mode: `1`, `true`, `on` or `yes` for dev mode, `0`, `false`, `off` or `no` for cloud mode, or the dev server's URL, which also means dev mode. `IsDev` in code overrides it, and `DevServerUrl` sets the dev server's address. A value the SDK cannot read stops the app with an error rather than silently falling back to cloud mode. ## Keys The event key lets the app send events; the signing key authenticates the traffic between Inngest and the app. Both come from the Inngest dashboard and belong in a secret store, not in source control. In cloud mode, an app without a signing key answers every request from Inngest with an error, and the [Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md) worker refuses to start. Inngest's event API takes the event key in the URL path, as in `https://inn.gs/e/`. The SDK's `HttpClient` logs no URLs for that reason, but HttpClient tracing records the full URL as `url.full`. If the app exports HttpClient spans, filter or redact them for the event API. To rotate the signing key without downtime, deploy the new key as `INNGEST_SIGNING_KEY` and the old one as `INNGEST_SIGNING_KEY_FALLBACK`, promote the new key in Inngest, then remove the fallback. The SDK accepts signatures from either key while both are set, and retries API calls with the fallback when the current key is refused. Inngest describes the procedure in [rotating keys](https://www.inngest.com/docs/platform-and-operations/rotate-event-and-signing-keys). ## Branch environments `Environment` selects an Inngest [branch environment](https://www.inngest.com/docs/platform-and-operations/environments-and-branch-deploys), such as a preview deployment. When neither the option nor `INNGEST_ENV` is set, the SDK uses the branch name that common hosting platforms expose, from the first of `BRANCH_NAME`, `VERCEL_GIT_COMMIT_REF`, `BRANCH`, `CF_PAGES_BRANCH`, `RENDER_GIT_BRANCH` and `RAILWAY_GIT_BRANCH` that is set. ## Behind a proxy When Inngest syncs the app, the SDK reports the URL Inngest should call, built from the request it received. Behind a reverse proxy, a load balancer or a container platform, that may be an internal address. Set the public origin, and the path if the proxy rewrites it: ```csharp options.ServeOrigin = new Uri("https://shop.example.com"); options.ServePath = "/api/inngest"; ``` `INNGEST_SERVE_HOST`, the old name of `INNGEST_SERVE_ORIGIN`, is read too. Proxies and load balancers also close a connection that stays idle too long, often after 60 seconds, and that cuts off a long call: a step that runs for minutes, or a long function without steps. With streaming on, a call still running after three seconds is answered at once with a 201, then with a space every three seconds, then with its result, which Inngest reads from the stream: ```csharp options.Streaming = true; ``` `INNGEST_STREAMING=true` does the same, as it does for the TypeScript SDK. A call that ends within three seconds is answered as usual. Connect workers need no streaming: their connection to Inngest stays open. ## Syncs A sync tells Inngest which functions the app has. Inngest starts one by sending a `PUT` to the endpoint: - In an **in-band** sync, the request is signed and the SDK answers with its configuration directly. This is the default when Inngest asks for it. `AllowInBandSync = false` forces the out-of-band kind. - In an **out-of-band** sync, the SDK posts its configuration to Inngest's API, authenticated with the signing key. A deploy script can start one with an unsigned `PUT`, such as `curl -X PUT https://shop.example.com/api/inngest`. The SDK only ever sends its configuration to the Inngest it is configured for, but without `ServeOrigin` the URL in it comes from the request's `Host` header, which an unsigned caller can set to any host. In production, set `ServeOrigin` (or `INNGEST_SERVE_ORIGIN`) or limit `AllowedHosts`; the SDK logs a warning when an unsigned sync takes the URL from the request. `AllowUnsignedSync = false` turns unsigned syncs off in cloud mode. See [apps and syncs](https://www.inngest.com/docs/platform-and-operations/apps-and-syncs) in the Inngest docs. ## Self-hosted Inngest `BaseUrl` points both the API and the event API at one address, such as a [self-hosted Inngest server](https://www.inngest.com/docs/platform-and-operations/self-host-inngest). `ApiBaseUrl` and `EventApiBaseUrl` set them separately. In dev mode they default to the dev server. ## Serialization `SerializerOptions` is used for everything the SDK serializes on the app's behalf: event data, step results, invoke payloads and function output. It defaults to `JsonSerializerDefaults.Web`. Add converters or naming policies there: ```csharp options.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); ``` The SDK takes a copy when it starts, so later changes have no effect. For trimmed or Native AOT apps, see [Native AOT and trimming](https://damikun.github.io/inngest-dotnet/guides/native-aot.md). ## Connect settings The [Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md) worker has settings of its own in `ConnectOptions`, with `INNGEST_CONNECT_MAX_WORKER_CONCURRENCY` and `INNGEST_CONNECT_GATEWAY_URL` as fallbacks. --- # Connect: workers without a public endpoint > Run functions in a worker that holds an outbound WebSocket to Inngest, with no public endpoint, using Inngest.Connect and AddConnect. Web page: https://damikun.github.io/inngest-dotnet/guides/connect/ With [Connect](https://www.inngest.com/docs/durable-execution/deploying-functions/connect), the app opens a WebSocket to Inngest and receives work over it, instead of exposing an HTTP endpoint for Inngest to call. That suits workers behind a firewall or a NAT, apps without a public URL, and long-running processes such as a container that should not also be a web server. The functions are the same. Only the way they are served changes. Figure: With `MapInngest`, Inngest calls the app over HTTPS, so the app must be reachable. With Connect, the worker opens a WebSocket to Inngest and receives work over it, so nothing has to reach the worker from outside. ## Set up a worker Install `Inngest.Connect` from NuGet. It brings in `Inngest`: ```bash dotnet add package Inngest.Connect ``` Then call `AddConnect` instead of mapping an endpoint. A worker needs no web server at all; the generic host is enough: ```csharp title="Program.cs" using Inngest; var builder = Host.CreateApplicationBuilder(args); builder.Services.AddSingleton(); builder.Services .AddInngest(options => options.AppId = "shop-worker") .AddFunction() .AddConnect(connect => { connect.InstanceId = Environment.GetEnvironmentVariable("HOSTNAME"); connect.MaxWorkerConcurrency = 20; }); await builder.Build().RunAsync(); ``` Functions are defined and registered the same way for a worker as for an endpoint, with their options as attributes on the class or [in code](https://damikun.github.io/inngest-dotnet/guides/functions-and-triggers.md#code-first-or-attributes). `AddConnect` registers a hosted service. It connects when the host starts and syncs the app through the connection, so there is no URL to sync and nothing to call `PUT` on. An ASP.NET Core app can use `AddConnect` too, alongside its own endpoints; it then needs no `MapInngest`. ## Options | Option | Variable | Meaning | | - | - | - | | `InstanceId` | | Identifies this worker to Inngest, which tracks leases and capacity per instance. Keep it stable across restarts of one worker and unique between workers. Defaults to the host name, as in the TypeScript SDK; workers that share a host, such as several processes, need an id each. | | `MaxWorkerConcurrency` | `INNGEST_CONNECT_MAX_WORKER_CONCURRENCY` | The most executions Inngest sends to this worker at once. Unlimited by default. | | `GatewayUrl` | `INNGEST_CONNECT_GATEWAY_URL` | A `ws://` or `wss://` URL to dial instead of the gateway Inngest assigns, for when a proxy or port mapping sits in between. | The app id, keys, environment and Inngest's address come from `InngestOptions` as usual; see [Configuration](https://damikun.github.io/inngest-dotnet/guides/configuration.md). In cloud mode the worker needs a signing key and refuses to start without one. ## Run it locally The dev server runs a Connect gateway on port 8289. Publish that port as well as 8288, and start the worker in dev mode: ### Install script ```bash curl -sSfL https://cli.inngest.com/install.sh | sh inngest dev --no-discovery ``` ### Homebrew ```bash brew install inngest/tap/inngest inngest dev --no-discovery ``` ### npx ```bash npx --ignore-scripts=false inngest-cli@latest dev --no-discovery ``` ### .NET tool ```bash dotnet tool install --global dotnet-inngest dotnet inngest dev --no-discovery ``` The tool downloads the Inngest CLI once, checks its SHA-256 and runs it. ### Docker ```bash docker run --rm -p 8288:8288 -p 8289:8289 inngest/inngest:v1.49.0 inngest dev --no-discovery ``` ### Compose ```yaml title="compose.yaml" services: inngest: image: inngest/inngest:v1.49.0 command: inngest dev --no-discovery ports: - "8288:8288" - "8289:8289" ``` ```bash docker compose up ``` ```bash INNGEST_DEV=1 dotnet run ``` The app appears in the dev server's Apps page as soon as the worker connects. `--no-discovery` stops the dev server from looking for HTTP apps, which a worker does not have. In an Aspire AppHost, [`AddInngest`](https://damikun.github.io/inngest-dotnet/guides/aspire.md) runs the dev server for you, and `WithReference` gives the worker the address of the gateway, which Aspire publishes on a port of its own. ## What the worker does Once connected, the worker: - receives each call as a message, acknowledges it and runs it through the same execution engine as an HTTP call, so functions behave the same either way; - extends the call's lease while it runs, so Inngest does not hand it to another worker; - sends heartbeats, and reconnects with backoff, from one second up to five minutes, when the connection drops; - opens a new connection before closing the old one when Inngest drains a gateway; - switches to the fallback signing key when Inngest refuses the current one. A slow function never holds up heartbeats or lease extensions: the socket has its own reader and writer, and functions run on the thread pool. ## Shutting down When the host stops, the worker tells Inngest to send it no more work, waits for running calls to finish, and closes the connection. Calls still running when the host's shutdown timeout passes are cancelled through their `CancellationToken`, and Inngest retries them elsewhere. Give the host enough time for your longest step: ```csharp builder.Services.Configure(options => options.ShutdownTimeout = TimeSpan.FromMinutes(2)); ``` On platforms that stop containers with `SIGTERM`, such as Kubernetes or ECS, set the platform's grace period a little longer than that timeout. The [Connect worker example](https://damikun.github.io/inngest-dotnet/examples/connect-worker.md) is a complete worker with a Dockerfile. --- # Aspire: the Inngest server in your AppHost > Run the Inngest dev server or a self-hosted server as a container of an Aspire AppHost, link its UI in the dashboard, and give projects the settings the SDK reads, with Inngest.Aspire.Hosting. Web page: https://damikun.github.io/inngest-dotnet/guides/aspire/ [Aspire](https://aspire.dev) starts the parts of an app on a developer machine, which are projects and containers, and connects them. `Inngest.Aspire.Hosting` adds the Inngest server to that: the AppHost runs it as a container, links its UI in the Aspire dashboard, gives the projects the settings the SDK reads to find it, and tells it where the apps that serve functions are. There is no `docker run` and no port to copy between terminals. It is for local development. It starts the dev server, or optionally a self-hosted server that signs its requests, and nothing here deploys Inngest. ## Install Add the package to the AppHost project, not to the apps: ```bash dotnet add package Inngest.Aspire.Hosting ``` The package needs Aspire 13.6.4 or later. It depends on `Aspire.Hosting` 13.6.4, so an AppHost on an older 13.x is raised to that version. It targets `net8.0` and `net10.0`, and it has no dependency on the `Inngest` package, because the AppHost only has to know the names of the settings, not the SDK. It runs in the AppHost, which is never trimmed or published as a native binary, so unlike the other packages it is not marked as trim or Native AOT compatible; `Aspire.Hosting` is not either. nuget.org reserves the `Aspire.` prefix for the Aspire team and its partners, so a community package cannot be called `Aspire.Hosting.Inngest`; hence `Inngest.Aspire.Hosting`, like `CommunityToolkit.Aspire.Hosting.*`. ## Add the server `AddInngest` adds the dev server. `WithReference` gives a project the settings that make its SDK use that server, and `WithApp` tells the server to sync the project, which serves its functions with `MapInngest`: ```csharp title="AppHost/Program.cs" var builder = DistributedApplication.CreateBuilder(args); var inngest = builder.AddInngest("inngest"); var shop = builder.AddProject("shop") .WithReference(inngest) .WaitFor(inngest); inngest.WithApp(shop); builder.Build().Run(); ``` `WithApp` builds the address from the endpoint of the project that is named `http`. A project made from a template has one from the `http` profile of its `launchSettings.json`. Without one the server fails to start with "The endpoint `http` is not defined for the resource": give the project one with `WithHttpEndpoint(name: "http")`, or pass the name of another endpoint to `WithApp`. Run the AppHost and the dashboard shows two resources. `inngest` is healthy once the server answers `GET /health` and its Connect gateway answers `GET /ready`, and its row links to the server's UI as **Inngest UI**. `shop` starts once the server is healthy, because of `WaitFor`. The server then syncs `shop` and lists its functions in the UI; sending an event runs them. The repository has such an AppHost for its sample app, in [`samples/Inngest.Sample.AppHost`](https://github.com/damikun/inngest-dotnet/tree/main/samples/Inngest.Sample.AppHost). What the three calls do: - **`AddInngest`** runs the `inngest/inngest` image at the tag `v1.49.0`, the Inngest CLI version the tests of this SDK run against, as `inngest dev --no-discovery`. Discovery would only scan the container's own ports. It has two endpoints: `http`, which is the REST API, the event API and the UI, and `connect`, which is the gateway of [Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md) workers. Aspire proxies both on ports it picks. Change the tag with `WithImageTag`, and fix the ports with the `port` and `connectPort` arguments. - **`WithReference`** sets the environment variables below on the project. It does not make the project wait for the server. - **`WithApp`** adds the project's URL to the arguments of the server (`-u`). The server calls it to sync the functions, and again every 5 seconds, which is the dev server's polling interval, so a project that starts after the server, or restarts with other functions, is found. Until the first sync the server knows no function of the project, so an event that reaches it by then starts no run. Do not make the server wait for the project with `WaitFor`: the project would wait for the server and the server for the project. ## What a project gets `WithReference(inngest)` sets these variables, which the SDK reads as described in [Configuration](https://damikun.github.io/inngest-dotnet/guides/configuration.md): | Variable | Dev server | Self-hosted server | | - | - | - | | `INNGEST_DEV` | the address of the server, which turns on dev mode and points the SDK at it | `0` | | `INNGEST_BASE_URL` | the address of the server | the address of the server | | `INNGEST_API_BASE_URL` | the address of the server | the address of the server | | `INNGEST_EVENT_API_BASE_URL` | the address of the server | the address of the server | | `INNGEST_SIGNING_KEY` | | the signing key | | `INNGEST_EVENT_KEY` | | the event key | | `INNGEST_CONNECT_GATEWAY_URL` | the address of the gateway | the address of the gateway | The addresses are worked out when the project starts, for that project. A project on the host gets `http://localhost:`, where the port is the one Aspire proxies the server on. A container gets the name of the server on the container network. The same `WithReference` is right for both, and nothing in the app has to know about Aspire: the app is an ordinary Inngest app that reads its settings from the environment. `INNGEST_DEV` is set to the address in dev mode so that any SDK that reads the address from it, such as the TypeScript one, finds the server too; `INNGEST_BASE_URL` has the same address for SDKs that read only that. The SDK reads `INNGEST_API_BASE_URL` and `INNGEST_EVENT_API_BASE_URL` before `INNGEST_BASE_URL`, so they are set as well. A self-hosted server sets `INNGEST_DEV` to `0` on purpose. Projects inherit the environment of the AppHost, and the settings above win over the same variables in it, so an `INNGEST_DEV` or an `INNGEST_API_BASE_URL` left in a shell does not send a project elsewhere or switch off the checks of signatures. Every other `INNGEST_*` variable of that environment still reaches the projects, and some of them break a run without saying so. An `INNGEST_SERVE_ORIGIN` makes the server call an address that is not the project's, so its runs cannot be delivered. Unset such a variable before you start the AppHost, or replace it for one project with `WithEnvironment`. ## How the server reaches an app The server runs in a container, where `localhost` is the container itself. `WithApp` does not write an address; it gives the server a reference to the endpoint of the project, and Aspire resolves that for the server's container when it starts: - A project that runs on the host is reached through the container tunnel that Aspire 13 starts, at an address like `aspire.dev.internal:`. The tunnel does not depend on `host.docker.internal`, so the integration adds no `--add-host=host.docker.internal:host-gateway`, which the plain Docker command of [Getting started](https://damikun.github.io/inngest-dotnet/guides/getting-started.md#4-run-it) needs on Linux. Where the tunnel is turned off, Aspire falls back to the host name of the container runtime, which is `host.docker.internal` on Docker Desktop. The tests of this repository have run on Docker Desktop for Mac only. - An app that is itself a container is reached by its name on the container network. The SDK registers the address that the server synced it through, so each step of a run calls the app at the same address, and the server needs no other. `WithApp` takes the path the app serves Inngest on and the name of its endpoint, `/api/inngest` and `http` by default: ```csharp var inngest = builder.AddInngest("inngest"); var billing = builder.AddProject("billing") .WithHttpEndpoint(name: "internal") .WithReference(inngest); inngest.WithApp(billing, "/webhooks/inngest", endpointName: "internal"); ``` Call it once for every app that serves functions. An app that only sends events needs no `WithApp`, and neither does a Connect worker. Use an `http` endpoint. An `https` endpoint of a project uses the ASP.NET Core development certificate, which the container does not trust unless you set that up with Aspire's certificate trust settings. ## Connect workers A [Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md) worker has no endpoint, so there is nothing to register with `WithApp`. It only needs `WithReference`: ```csharp builder.AddProject("worker") .WithReference(inngest) .WaitFor(inngest); ``` The worker calls the API of the server to start a connection, and the server answers with the address of its gateway. That address is made of the host the worker called and the port the gateway has inside the container, and Aspire publishes the gateway on another one, so a worker that used it would dial a port that nothing on the host listens on. That is why `WithReference` sets `INNGEST_CONNECT_GATEWAY_URL`, which the SDK dials instead. The address is that of the `connect` endpoint, resolved for the worker like the others. A worker that is started by other means than this reference gets the server's answer and has to set the variable itself. ## A self-hosted server The dev server checks nothing: it does not sign its requests and accepts any event key. `AddInngestSelfHosted` runs `inngest start` instead, which signs every request to an app with a signing key, wants the key on its REST API and refuses events that do not carry its event key. A project that references it runs in cloud mode with the same keys, as it would in production: ```csharp var signingKey = builder.AddParameter("inngest-signing-key", secret: true); var eventKey = builder.AddParameter("inngest-event-key", secret: true); var inngest = builder.AddInngestSelfHosted("inngest", signingKey, eventKey); var shop = builder.AddProject("shop") .WithReference(inngest) .WaitFor(inngest); inngest.WithApp(shop); ``` The keys are secret parameters, which Aspire reads from the user secrets or the configuration of the AppHost, or asks for in the dashboard. The server takes them from `INNGEST_SIGNING_KEY` and `INNGEST_EVENT_KEY` in its environment, so they are not in its command line. `inngest start` accepts only a signing key that is a hex string of even length, without a `signkey-` prefix, such as the output of `openssl rand -hex 32`. The SDK accepts that key as it is. When the server starts, the AppHost checks the keys and reports one that `inngest start` would refuse, instead of leaving you with a container that stopped. Leave the keys out and the integration generates them, as Aspire does for the passwords of other containers: ```csharp var inngest = builder.AddInngestSelfHosted("inngest"); ``` A generated signing key has 64 digits, which are valid hex, and a generated event key is letters and digits. Both are kept in the user secrets of the AppHost, when it has them, as `Parameters:inngest-signing-key` and `Parameters:inngest-event-key`, so that they stay the same between runs, and a value you put there wins. Unlike the dev server, the self-hosted server polls for apps only when it is told to, so the integration starts it with a polling interval of 5 seconds. ## Keeping the state The dev server holds its runs and events in memory, and the self-hosted server writes a SQLite database, `main.db`, to `/.inngest` inside its container. Both lose their state when the container is removed, because the integration adds no volume. Inngest v1.49.0 can keep it: `inngest dev --persist` writes the dev server's database to the same directory, and a volume at that directory survives the container. Pass the flag with `WithArgs` and add the volume: ```csharp var inngest = builder.AddInngest("inngest") .WithArgs("--persist") .WithVolume("inngest-data", "/.inngest"); ``` The self-hosted server always writes its database, so it needs only the volume. `inngest start` also takes `--sqlite-dir ` to move the database, and then the volume goes to that directory: ```csharp var inngest = builder.AddInngestSelfHosted("inngest") .WithVolume("inngest-data", "/.inngest"); ``` `WithLifetime(ContainerLifetime.Persistent)` keeps the container itself, memory and all, across runs of the AppHost. The integration starts no Postgres or Redis for the self-hosted server; `inngest start` takes their addresses as `--postgres-uri` and `--redis-uri`. ## In a manifest `aspire publish` writes a manifest that deployment tools turn into a deployment. What it holds depends on the server: - **The dev server is not in it.** It checks no signatures, so a deployment must not run it. `WithReference` to the dev server adds nothing to a manifest either, because `INNGEST_DEV` would put the deployed app into dev mode. The app deploys in cloud mode, with the keys and addresses that you give it where it is deployed. - **The self-hosted server is in it**, as a container resource: the image and tag, the entrypoint and the arguments (`start --poll-interval 5` and a `-u` for each app, as an expression of the app's address), the `http` and `connect` endpoints, and the keys as the secret parameters `-signing-key` and `-event-key`, which the deployment asks for or generates. The projects that reference it get `INNGEST_DEV=0`, the addresses, the keys and the gateway address as references to that resource and those parameters. The self-hosted server keeps its state inside its container, as it does on your machine, so a deployment that replaces the container loses it, and the manifest has no volume or database for it. Treat the integration as a way to run Inngest while you develop; [Deploying](https://damikun.github.io/inngest-dotnet/guides/deploying.md) covers production. ## What it does not do - **It keeps no state beyond its container.** See [Keeping the state](https://damikun.github.io/inngest-dotnet/guides/aspire.md#keeping-the-state). - **It does not deploy Inngest.** The manifest has the self-hosted server as described above, but Inngest in production is Inngest Cloud or a server you run and size yourself. - **It cannot be called from an AppHost written in another language.** The methods are not exported for Aspire's polyglot AppHosts. ## Testing the AppHost The integration works under `Aspire.Hosting.Testing`, which is how this repository tests it. Its tests start the dev server, a small app and a Connect worker, wait for them to be healthy and run a function through the server, and do the same against the self-hosted server. They need Docker. Other tests write the manifest of both servers and read it. --- # Testing Inngest functions with xUnit > Unit test Inngest functions on the test host of Inngest.Testing, which replays them on the real execution engine with mocked steps, or with a fake IStepTools, and test them end to end against the Inngest dev server. Web page: https://damikun.github.io/inngest-dotnet/guides/testing/ There are three ways to test an Inngest function: - **The test host** of the [`Inngest.Testing`](https://www.nuget.org/packages/Inngest.Testing/) package runs the function on the SDK's real execution engine and plays Inngest's part: it calls the function again after every step, passes step results through JSON, retries failures and answers sleeps, waits and invokes from mocks. Nothing leaves the test process. - **A fake `IStepTools`** runs every step inline. It needs no package besides `Inngest` and checks the function's logic and the order of its steps, but not replays or the serialization of step results. - **The Inngest dev server** runs the whole app as Inngest runs it, flow control included. ## The function under test The unit tests on this page test this function. It sends a welcome email, waits up to three days for the user to activate, and sends a reminder when they do not: ```csharp title="OnboardUser.cs" using Inngest; public sealed record UserSignedUp(string UserId, string Email) : IInngestEventData { public static string EventName => "user/signed-up"; } public sealed record UserActivated(string UserId) : IInngestEventData { public static string EventName => "user/activated"; } public sealed record OnboardingResult(bool Activated); public interface IEmailSender { Task SendAsync(string to, string subject, CancellationToken cancellationToken); } [InngestFunction("onboard-user")] [EventTrigger] public sealed class OnboardUser(IEmailSender email) : InngestFunction { public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) { var user = context.Event.Data; await context.Step.RunAsync("send-welcome", ct => email.SendAsync(user.Email, "Welcome!", ct)); var activated = await context.Step.WaitForEventAsync("wait-for-activation", TimeSpan.FromDays(3), match: "data.userId"); if (activated is null) { await context.Step.RunAsync("send-reminder", ct => email.SendAsync(user.Email, "Your account is waiting", ct)); } return new OnboardingResult(activated is not null); } } ``` The tests replace the email service with one that records the subject of every email: ```csharp title="RecordingEmailSender.cs" public sealed class RecordingEmailSender : IEmailSender { public List Subjects { get; } = []; public Task SendAsync(string to, string subject, CancellationToken cancellationToken) { Subjects.Add(subject); return Task.CompletedTask; } } ``` ## The test host Add the package to the test project: ```bash dotnet add package Inngest.Testing ``` `InngestTestHost` takes a service collection with the app's registrations: `AddInngest`, the functions and middleware to test, and the services they use. These tests register the function as the app does and the recording email sender in place of the real one: ```csharp title="OnboardUserTests.cs" using Inngest; using Inngest.Testing; using Microsoft.Extensions.DependencyInjection; using Xunit; public sealed class OnboardUserTests { private static readonly InngestEvent SignedUp = new(UserSignedUp.EventName, new UserSignedUp("u_1", "ada@example.com")); private readonly RecordingEmailSender _email = new(); [Fact] public async Task Sends_a_reminder_when_the_user_does_not_activate() { await using var host = CreateHost(); var run = await host.RunAsync(SignedUp, new StepMocks().WaitForEventTimeout("wait-for-activation"), TestContext.Current.CancellationToken); Assert.Equal(TestRunStatus.Completed, run.Status); Assert.False(run.GetOutput()!.Activated); Assert.Equal(["send-welcome", "wait-for-activation", "send-reminder"], run.Steps.Select(step => step.Id)); Assert.Equal(["Welcome!", "Your account is waiting"], _email.Subjects); } [Fact] public async Task Skips_the_reminder_when_the_user_activates() { await using var host = CreateHost(); var activated = new InngestEvent(UserActivated.EventName, new UserActivated("u_1")); var run = await host.RunAsync(SignedUp, new StepMocks().WaitForEvent("wait-for-activation", activated), TestContext.Current.CancellationToken); Assert.True(run.GetOutput()!.Activated); Assert.Equal(["Welcome!"], _email.Subjects); } [Fact] public async Task Waits_three_days_for_the_same_user_to_activate() { await using var host = CreateHost(); var run = await host.ExecuteStepAsync("wait-for-activation", SignedUp, TestContext.Current.CancellationToken); Assert.Equal(TestRunStatus.Stopped, run.Status); var wait = run.GetStep("wait-for-activation"); Assert.Equal("3d", wait.Options.GetProperty("timeout").GetString()); Assert.Equal("event.data.userId == async.data.userId", wait.Options.GetProperty("if").GetString()); } private InngestTestHost CreateHost() { var services = new ServiceCollection(); services.AddSingleton(_email); services.AddInngest(options => options.AppId = "shop").AddFunction(); return new InngestTestHost(services); } } ``` `RunAsync` runs the function registered as `OnboardUser`, under the id it was registered with; `RunAsync("onboard-user", ...)` does the same by id. Both have overloads without mocks, and overloads that take an `IReadOnlyList` in place of the event to run a function that takes batches. A run goes on until the function completes, fails for good, or waits on a step that has no mock, and `RunAsync` then returns a `TestRun`. A function with its options [in code](https://damikun.github.io/inngest-dotnet/guides/functions-and-triggers.md#code-first-or-attributes) is registered with the same `FunctionOptions` as in the app, and runs the same way. The host copies the service collection, so tests can share one. It builds the services the way the app does at startup, so function options that Inngest would reject make the constructor throw. `host.Services` is the built container, and disposing the host disposes it. Runs are independent of each other, and one host can run several at once. ### What the host does - It calls the function again after every step, as Inngest does, so code outside steps runs once per call, and every step result reaches the function through JSON written with the app's serializer options. - It resolves the function, its middleware and the services they use from a new scope for every call, and the app's middleware runs around every call and step. A [scoped step](https://damikun.github.io/inngest-dotnet/guides/scoped-steps.md) runs its handler in a scope of its own on top of that. - It runs steps that start together in calls of their own, one per step, and calls the function again once all of them have finished, or after each one in [race mode](https://damikun.github.io/inngest-dotnet/guides/parallel-steps.md). - It retries a failing step or function at once, with the attempt numbers Inngest would send, until the function's `Retries` run out (4 retries when it sets none). A `NonRetriableException` fails at once, and a `RetryAfterException` is retried without waiting out its delay. - When a run fails for good, it runs the function's failure handler, if it has one, with the `inngest/function.failed` event Inngest would send, and with the same mocks. - It records the events the app sends instead of sending them. What it does not do: - It sends nothing to Inngest. The SDK's own `HttpClient` throws for every request, even to a dev server on the same machine, and the `INNGEST_*` environment variables are ignored, so the machine a test runs on does not change how the test runs. Options set in code and a configuration section passed to `AddInngest` still apply. - It does not apply what Inngest enforces outside the SDK: concurrency limits, throttling, rate limits, debounce, priority, idempotency, singletons, cancellation and timeouts. A batch is the list of events the test passes. - It does not check a wait mock's event against the wait's `Match` or `If`, only against the event name. - It does not carry out signal waits, fetch steps or inference steps, which cannot be mocked, so the run waits on them. Nor does it run invoked functions: an invoke without a mock leaves the run waiting. - It does not checkpoint. A function with checkpointing on runs one step per call in the host, as without it, so code outside steps runs more often than in production; the results are the same. ### Reading a run `TestRun` is the run as the host left it: | Member | What it holds | | - | - | | `Status` | `Completed` when the function returned, `Failed` when it failed for good, `Waiting` when it waits on steps the test did not mock or the host does not carry out, and `Stopped` when `ExecuteStepAsync` reached its step. | | `Output`, `GetOutput()` | What the function returned, as the JSON Inngest stores; null unless the run completed. `GetOutput()` reads it with the app's serializer options and throws `InvalidOperationException` when there is no output. | | `Error` | The exception the run failed with, as the function or the SDK threw it: a `StepFailedException` when a step failed for good and the function did not catch it. Null unless the run failed. | | `Steps`, `GetStep(id)` | Every step the run reached, in the order Inngest heard of them. `GetStep` finds one by the id the code passed, and throws `KeyNotFoundException` naming the steps the run reached when there is none. | | `WaitingOn` | The steps a waiting run waits on. | | `SentEvents` | The events the run sent, from send steps and through the client, in order. | | `FailureHandler` | The run of the failure handler, when the function failed for good and has one; otherwise null. | | `RunId`, `FunctionId` | The id of the run, and the id of the function without the app's prefix. | `ToString()` describes the outcome, such as `Waiting on step "wait-for-activation" (WaitForEvent).`, which makes a failed assertion easier to read. Each step is a `TestStep`: | Member | What it holds | | - | - | | `Id`, `HashedId` | The id the code passed, and the hash Inngest stores the step under. | | `Type` | The `StepType`, such as `Run`, `Sleep`, `WaitForEvent`, `Invoke` or `SendEvent`. Null for an operation the host does not know. | | `Op`, `Options` | The last operation the SDK reported for the step, such as `StepRun`, `Sleep` or `WaitForEvent`, and its options as JSON, such as the `duration` of a sleep or the `event`, `timeout` and `if` of a wait. | | `Status` | `Pending` when the step is planned or waits on something the test did not mock, `Completed`, or `Failed` when it failed for good. | | `Attempts` | How many times its handler, or its mock, ran. Zero for a sleep, wait or invoke, which Inngest carries out. | | `Mocked` | Whether a mock answered the step. | | `Output`, `GetOutput()` | The result the function receives, as JSON: the value of a run step or an invoke, the event a wait received, or JSON `null` for a sleep or a wait that timed out. Null unless the step completed. | | `Error`, `Errors` | What the step failed with for good, and what each failed attempt threw, including attempts that a retry made up for. | A step id that the code uses more than once, as in a loop, gets a `:` suffix from its second use on, so the third step named `page` is `page:2`, both in `GetStep` and in mocks. ### Mocking steps `StepMocks` holds what the host answers in place of steps, by step id. Every method returns a new set, so tests can share a set and add to it. | Method | What the step does | | - | - | | `Run(id, T result)` | Returns `result` instead of running its handler. An `AIWrapAsync` step and a `RunScopedAsync` step are mocked the same way, the latter then making no scope, and so is a model call or tool call of an [AI chat client](https://damikun.github.io/inngest-dotnet/guides/ai.md#testing). | | `Run(id, Func handler)` | Calls `handler` in place of the step's own handler on every attempt, with the attempt number from 0. Throw from it to fail the attempt. | | `RunThrows(id, exception)` | Throws on every attempt, so the step is retried and then fails for good, at once for a `NonRetriableException`. | | `Sleep(id)` | The sleep ends. | | `AllSleeps()` | Every sleep ends. | | `WaitForEvent(id, received)` | The wait receives `received`, which must have the name the step waits for. | | `WaitForEventTimeout(id)` | The wait times out and returns null. | | `Invoke(id, T result)` | The invoked function returns `result`. | | `Invoke(id, TResult result)` | The same for a step that invokes `TFunction` by its class. | | `InvokeThrows(id, exception)` | The invoked function fails, and the step throws `StepFailedException` where the function awaits it. | | `WaitForSignal(id, T data)` | The wait for a signal receives `data`, under the name of the signal it waits for. | | `WaitForSignalTimeout(id)` | The wait for a signal times out and returns null. | | `SendSignal(id, runId)` | The send-signal step returns `runId` as the run the signal resumed; null says no run waited. | | `Fetch(id, statusCode, body, headers)` | The fetch step gets this response, as Inngest would hand it back. | | `Infer(id, T response)` | The AI provider answers the inference step with `response`. | A mocked run step still goes through the engine: its value is serialized, memoized and replayed like a real result, and the app's middleware runs around it. A run step without a mock runs its handler. For a step that invokes a function by its class, as `InvokeAsync` does in [Invoking functions](https://damikun.github.io/inngest-dotnet/guides/invoking-functions.md), name the class in the mock too: ```csharp using Inngest.Testing; var mocks = new StepMocks() .Invoke("score-acme", new AccountScore("acme", 40)) .Invoke("score-globex", new AccountScore("globex", 60)); ``` The compiler checks that `ScoreAccount` returns an `AccountScore`, and the host checks that the step invokes `ScoreAccount`: when it invokes another function, `RunAsync` throws an `InvalidOperationException` that names both. `Invoke` without a class answers any invoke, by class or by id. A mock that does not fit its step makes `RunAsync` throw `InvalidOperationException` too: a mock for another kind of step, such as `Run` for a sleep, or a wait mock whose event has another name. Send steps take no mock; the host records their events. A signal sent without a mock resumes no run and is recorded too, and so are realtime messages. The steps of a [fan-out over a list](https://damikun.github.io/inngest-dotnet/guides/fan-out.md#testing) are mocked by their ids, such as `charge[0]`. Steps that start together, as with `Task.WhenAll`, hold each other up: without race mode Inngest calls the function again only once all of them have finished, so a wait without a mock keeps the run waiting even when the other steps of the group are mocked. ### Time Sleeps and wait timeouts end at the time they would end under Inngest, on a simulated clock: it starts at the app's time and moves forward only when something completes, so a run never waits in real time, and steps that run together end in the order of their end times. When the app's `TimeProvider` is a `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing`, the host moves it to the end of every sleep, so code that reads the clock after a sleep sees the time it would see under Inngest. ### Events `run.SentEvents` holds the events one run sent, and `host.SentEvents` every event sent through the app's client since the host was created, from runs or not. The host checks each event as the client does before sending, so an event with a reserved `inngest/` name fails its send step, and a send returns ids in the format Inngest gives events. An event sent through `IInngestClient` outside a step is sent again on every call, so it shows up once per call; send it from a step to send it once. Events sent from a run carry the run's [sessions](https://damikun.github.io/inngest-dotnet/guides/sending-events.md#sessions) in `Meta.PropagatedSessions`, and the host delivers an event's sessions to the run it starts. `run.SentSignals` and `host.SentSignals` hold the signals sent without a mock, each with its data as JSON, and `run.Published` and `host.Published` the [realtime](https://damikun.github.io/inngest-dotnet/guides/realtime.md) messages, a stream as one message with all its text and `IsStream` set. ### Running up to one step `ExecuteStepAsync` runs the function only until one step has run, after its retries, or until a step that Inngest carries out, such as a sleep or a wait, has been reached. The run then ends as `Stopped`, unless it completed, failed or started waiting before it got there. The last test above uses it to check how the function waits, with no mocks for what comes after the wait. `ExecuteStepAsync` finds the function by its class, and `ExecuteStepAsync(functionId, stepId, ...)` by its id. ## A fake for the step tools To test without the extra package, give the function a fake `IStepTools`. This one runs step handlers at once, returns at once from sleeps, answers waits from a dictionary, and records every step id in order: ```csharp title="InlineStepTools.cs" using Inngest; public sealed class InlineStepTools : IStepTools { public List Steps { get; } = []; public Dictionary Waits { get; } = []; public List Sent { get; } = []; public async Task RunAsync(string id, Func> handler) { Steps.Add(id); return await handler(CancellationToken.None); } public Task SleepAsync(string id, TimeSpan duration) { Steps.Add(id); return Task.CompletedTask; } public Task SleepUntilAsync(string id, DateTimeOffset until) { Steps.Add(id); return Task.CompletedTask; } public Task?> WaitForEventAsync(string id, WaitForEventOptions options) { Steps.Add(id); return Task.FromResult(Waits.TryGetValue(id, out var received) ? (InngestEvent?)received : null); } public Task InvokeAsync(string id, InvokeOptions options) => throw new NotSupportedException($"Step {id} invokes {options.FunctionId}; give the fake a result for it."); public Task SendEventAsync(string id, IReadOnlyList events) { Steps.Add(id); Sent.AddRange(events); return Task.FromResult(new SendEventsResult([.. events.Select((_, i) => $"{id}-{i}")])); } } ``` The SDK's extension methods for these steps, such as `RunAsync` with a handler that takes no cancellation token or the typed `WaitForEventAsync` and `SendEventAsync`, end up in these six methods, and so do their overloads that take a `StepOptions` in place of the id. `ParallelAsync` needs nothing from the fake. The other members of `IStepTools` have defaults that throw `NotSupportedException`: `WaitForSignalAsync`, `SendSignalAsync`, `FetchAsync`, `InferAsync`, `AIWrapAsync`, `RunScopedAsync`, `PublishAsync`, `PublishStreamAsync` and `InvokeAsync`, which invokes by class and needs the app's registrations to find the function's id. Implement the ones the function under test calls; [Scoped steps](https://damikun.github.io/inngest-dotnet/guides/scoped-steps.md#testing) shows `RunScopedAsync`. `FunctionContext` has public `init` properties, so a test builds one directly: ```csharp title="OnboardUserFakeTests.cs" using Inngest; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Xunit; public sealed class OnboardUserFakeTests { private readonly RecordingEmailSender _email = new(); private readonly InlineStepTools _steps = new(); [Fact] public async Task Sends_a_reminder_when_the_user_does_not_activate() { await new OnboardUser(_email).ExecuteAsync(Context(), TestContext.Current.CancellationToken); Assert.Equal(["send-welcome", "wait-for-activation", "send-reminder"], _steps.Steps); Assert.Equal(["Welcome!", "Your account is waiting"], _email.Subjects); } [Fact] public async Task Skips_the_reminder_when_the_user_activates() { _steps.Waits["wait-for-activation"] = new InngestEvent { Name = UserActivated.EventName, Data = new("u_1") }; await new OnboardUser(_email).ExecuteAsync(Context(), TestContext.Current.CancellationToken); Assert.Equal(["send-welcome", "wait-for-activation"], _steps.Steps); } private FunctionContext Context() { var signedUp = new InngestEvent { Name = UserSignedUp.EventName, Data = new("u_1", "ada@example.com") }; return new FunctionContext { FunctionId = "onboard-user", RunId = "test-run", Attempt = 0, Step = _steps, Logger = NullLogger.Instance, Services = new ServiceCollection().BuildServiceProvider(), Event = signedUp, Events = [signedUp], }; } } ``` These tests check the function's logic and the order of its steps. They do not exercise replays or the serialization of step results, which the test host does. ## Functions that read the user A function that takes `ICurrentUser` from [`Inngest.UserContext`](https://damikun.github.io/inngest-dotnet/guides/user-context.md#testing) is tested with a fake `ICurrentUser` in the services of the host. Add the real middleware to the services instead to test how the user travels: the host restores the user of the event it is given, and records the events a function sends with the user they carry. ## End to end against the dev server Flow control, cancellation and the expressions of triggers and waits are applied by Inngest, so only a run against Inngest exercises them. To test the whole path, run the app against an [Inngest dev server](https://www.inngest.com/docs/local-development) in a container, send an event and wait for the function to do its work. The [`Inngest.Testing.Containers`](https://www.nuget.org/packages/Inngest.Testing.Containers/) package starts the server from your tests and has it sync the app. For an ASP.NET Core app on .NET 10, the separate [`Inngest.Testing.Containers.AspNetCore`](https://www.nuget.org/packages/Inngest.Testing.Containers.AspNetCore/) package adds a `WebApplicationFactory` that runs the app against that server. See [Testing with containers](https://damikun.github.io/inngest-dotnet/guides/testing-with-containers.md). Inngest's [testing guide](https://www.inngest.com/docs/durable-execution/guides-and-advanced/testing) covers the same ideas for its other SDKs. ## Related examples The functions of the [examples](https://damikun.github.io/inngest-dotnet/examples/order-fulfillment.md) can be tested the same way. --- # Testing against a real Inngest server > Start the Inngest dev server or a self-hosted server in Docker from your tests with Inngest.Testing.Containers, point an app at it and sync it, and run an ASP.NET Core app under WebApplicationFactory with Inngest.Testing.Containers.AspNetCore. Web page: https://damikun.github.io/inngest-dotnet/guides/testing-with-containers/ The [test host](https://damikun.github.io/inngest-dotnet/guides/testing.md) runs your functions in memory and plays Inngest's part. It cannot tell you what only Inngest does: flow control, cancellation, the expressions of triggers and waits, signed requests, or how the server calls an app that is really listening on a port. For those, run the app against an Inngest server. [`Inngest.Testing.Containers`](https://www.nuget.org/packages/Inngest.Testing.Containers/) is a [Testcontainers](https://dotnet.testcontainers.org/) module for that. It starts the `inngest/inngest` image, either as the dev server or as a self-hosted server with signing and event keys, and gives you what a test needs to use it: its address, the settings that point an app at it, and a call that has it sync the app. [`Inngest.Testing.Containers.AspNetCore`](https://www.nuget.org/packages/Inngest.Testing.Containers.AspNetCore/) adds a `WebApplicationFactory` that runs your ASP.NET Core app on a real port for the server to call. You need Docker running on the machine that runs the tests, with Linux containers. Like every Testcontainers module, the builder takes the image you choose; the module is tested with `inngest/inngest:v1.49.0`, and starts the server with command line flags that older images may not know. ```bash dotnet add package Inngest.Testing.Containers ``` ## The dev server `InngestServerBuilder` takes the image and builds an `InngestServerContainer`. Start it, and tell the app where it is with `Configure`, which sets the options of the SDK: dev mode, and the address of the server. The server and the app have to reach each other, so neither can be told the other's address before it starts. Start the container first, then the app, and then have the container sync the app at the address it listens on. This fixture runs an ASP.NET Core app on a port the system picks. It uses `MapInngest`, so the test project needs `Inngest.AspNetCore` and the ASP.NET Core framework, which it has when it references your app: ```csharp title="InngestFixture.cs" using Inngest; using Inngest.Testing.Containers; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Xunit; public sealed class InngestFixture : IAsyncLifetime { private WebApplication? _app; public InngestServerContainer Server { get; } = new InngestServerBuilder("inngest/inngest:v1.49.0").Build(); public async ValueTask InitializeAsync() { await Server.StartAsync(); var builder = WebApplication.CreateBuilder(); // All interfaces, not loopback: the container reaches the app through the Docker bridge on Linux. builder.WebHost.ConfigureKestrel(kestrel => kestrel.ListenAnyIP(0)); builder.Services.AddSingleton(); builder.Services .AddInngest(options => { options.AppId = "shop"; Server.Configure(options); }) .AddFunction(); _app = builder.Build(); _app.MapInngest("/api/inngest"); await _app.StartAsync(); await Server.SyncAsync(new Uri(new Uri(_app.Urls.First()), "/api/inngest")); } public async ValueTask DisposeAsync() { if (_app is not null) { await _app.DisposeAsync(); } await Server.DisposeAsync(); } } ``` `StartAsync` returns when the server answers its health check. If it never does, the exception carries the server's log. `Configure` sets `IsDev`, `DevServerUrl` and `BaseUrl`, replacing addresses the app set in code, and it must run after the container has started, since the port is only known then. The container publishes the API on a free port of the machine, so parallel test runs never collide. `GetBaseAddress()` is that address, for a test that talks to the server itself, for instance to send an event or read a run over its [REST API](https://www.inngest.com/docs/reference/rest-api). ### How the server finds the app `SyncAsync` sends the app's Inngest endpoint a `PUT`, which an SDK answers by registering its functions with the server under the URL the request arrived on; the server calls that URL later. Inside the container `localhost` is the container itself, so a loopback address, or `0.0.0.0` or `[::]`, which Kestrel reports for an app on all interfaces, goes out with `host.docker.internal` and the port as its host. Any other address, such as the name of a container on a shared network, is used as given. Docker Desktop resolves `host.docker.internal` by itself, and on Linux the builder maps it to the host gateway. The request also says which kind of server it comes from, `dev` or `cloud`, as the server's own sync does, and the self-hosted server's request is signed with its signing key. `SyncAsync` returns once the server knows the app's functions, and throws when the app does not answer or refuses. Syncing again is harmless. An app that sets `ServeOrigin` (or `INNGEST_SERVE_ORIGIN`) registers that origin instead of the one the request names, which the container cannot reach; leave it unset in tests. When the app's port is known before anything starts, the container can do the syncing: `WithAppUrl` adds the address to the command line, and the server syncs the app when it starts and keeps trying every second until the app answers. The app may start before or after the server. ```csharp using Inngest.Testing.Containers; var server = new InngestServerBuilder("inngest/inngest:v1.49.0") .WithAppUrl("http://localhost:5280/api/inngest") .Build(); ``` A loopback address in the URL is rewritten to `host.docker.internal`, and `WithAppUrl` can be called once for each app. `WithAppUrl` gives no signal when the sync has happened, and an event sent before the app is registered starts no run, because the server has no function for it. `SyncAsync` returns when the server knows the app, so use it where a test has to know. After `WithAppUrl`, the test waits for the app to show up. The apps query of the GraphQL API that the Dev UI uses lists the apps the server has synced: ```csharp using System.Net.Http.Json; using System.Text.Json; using Inngest.Testing.Containers; using var http = new HttpClient { BaseAddress = server.GetBaseAddress() }; while (true) { using var response = await http.PostAsJsonAsync("v0/gql", new { query = "{ apps { externalID functionCount } }" }, cancellationToken); var body = await response.Content.ReadFromJsonAsync(cancellationToken); var apps = body.GetProperty("data").GetProperty("apps").EnumerateArray(); if (apps.Any(app => app.GetProperty("externalID").GetString() == "shop" && app.GetProperty("functionCount").GetInt32() > 0)) { break; } await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); } ``` ## An app under WebApplicationFactory A `WebApplicationFactory` runs the app in memory, and an in-memory server has no port for Inngest to call. From .NET 10 the factory can run Kestrel instead, and `InngestWebApplicationFactory` in [`Inngest.Testing.Containers.AspNetCore`](https://www.nuget.org/packages/Inngest.Testing.Containers.AspNetCore/) does everything the fixture above does: it listens on all interfaces on a free port, applies `Configure` to the app's options after the app's own, and syncs the app. ```bash dotnet add package Inngest.Testing.Containers.AspNetCore ``` Derive from it where you would derive from `WebApplicationFactory`, and override `ConfigureWebHost` for the fakes of your own. This one replaces the email service with one that tells the test when it sent something, which is how a test sees what a function did: ```csharp title="Program.cs" // The last line of the app's Program.cs, which makes the class that top-level statements generate visible to tests. public partial class Program; ``` ```csharp title="OnboardingTests.cs" using Inngest; using Inngest.Testing.Containers; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Xunit; public sealed class SignalingEmailSender : IEmailSender { private readonly TaskCompletionSource _sent = new(TaskCreationOptions.RunContinuationsAsynchronously); public Task Sent => _sent.Task; public Task SendAsync(string to, string subject, CancellationToken cancellationToken) { _sent.TrySetResult($"{subject} to {to}"); return Task.FromResult("message-id"); } } public sealed class ShopFactory(InngestServerContainer inngest, IEmailSender email) : InngestWebApplicationFactory(inngest) { protected override void ConfigureWebHost(IWebHostBuilder builder) => builder.ConfigureServices(services => services.AddSingleton(email)); } public sealed class OnboardingTests : IAsyncLifetime { private readonly SignalingEmailSender _email = new(); private readonly InngestServerContainer _server = new InngestServerBuilder("inngest/inngest:v1.49.0").Build(); private ShopFactory? _app; public async ValueTask InitializeAsync() { await _server.StartAsync(); _app = new ShopFactory(_server, _email); await _app.StartAsync(); } [Fact] public async Task A_user_who_signs_up_gets_a_welcome_email() { var inngest = _app!.Services.GetRequiredService(); await inngest.SendAsync(new UserSignedUp("u_1", "ada@example.com")); Assert.Equal("Welcome! to ada@example.com", await _email.Sent.WaitAsync(TimeSpan.FromSeconds(30))); } public async ValueTask DisposeAsync() { if (_app is not null) { await _app.DisposeAsync(); } await _server.DisposeAsync(); } } ``` The event goes from the app's own `IInngestClient` to the container, the server calls the app's endpoint over the socket, and the function sends the email through the fake. `factory.CreateClient()` returns a client for the same socket, so a test can also call the app's own endpoints, such as one that sends the event. Some things to know about the factory: - It targets .NET 10. `WebApplicationFactory.UseKestrel` was added in that release; before it, a real port needs a second host built next to the in-memory one, which runs the app's hosted services twice and splits its state in two. On .NET 8 and 9, host the app yourself as above. - Start the container first. The app's settings hold its address, so creating the host throws while the container is not running. - `StartAsync` starts the app, then syncs it. Creating a client or reading `Services` first starts the app too, but the server does not know it until `StartAsync`. - Every client reaches the app at `localhost` and its port. Kestrel reports an app on all interfaces as `[::]`, which a client cannot connect to on Windows, and a factory hands `http://localhost`, port 80, to options of its own that have no address. So the factory declares `CreateClient` again and configures what `CreateDefaultClient` returns. Use it through its own type: a variable typed as `WebApplicationFactory` calls the original `CreateClient`. - The app runs on Kestrel, so the factory's `Server` property, the in-memory `TestServer`, throws `NotSupportedException`. - Customize the app by overriding `ConfigureWebHost`. A factory returned by `WithWebHostBuilder` keeps the Inngest settings but is another instance of the app on the in-memory server, because ASP.NET Core does not carry the Kestrel choice over to it. - The server calls the app over plain HTTP, so turn off HTTPS redirection in the test. - One container serves one instance of an app id. Syncing a second app with the same id replaces the first. ## The self-hosted server The dev server signs nothing. `inngest start` is the self-hosted server: it signs every request it makes to the app, wants the signing key on its REST API and an event key on events, so an app in cloud mode with the same keys exercises request signing for real. `WithSelfHosting` runs it. Keys you do not give are generated, and the container tells them: ```csharp using Inngest.Testing.Containers; var server = new InngestServerBuilder("inngest/inngest:v1.49.0") .WithSelfHosting() .Build(); await server.StartAsync(); // Hexadecimal digits in pairs: inngest start accepts nothing else as a signing key. string signingKey = server.SigningKey!; string eventKey = server.EventKey!; ``` `Configure` then sets cloud mode with the keys and the server's address. `SyncAsync` signs its request with the signing key. To use keys of your own, call `WithSigningKey` and `WithEventKey` after `WithSelfHosting`; the signing key must be plain hexadecimal digits in pairs, without the `signkey-env-` prefix that Inngest Cloud keys carry, and the builder rejects anything else when it builds. ## Connect workers A worker that uses [Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md) reaches the server through its gateway, and the server hands the worker the gateway's address as it is inside the container. `WithConnectGateway` publishes the gateway on the same port on the machine, picks a free one when you give none, and makes the container wait for the gateway as well as the API. The worker needs no URL to sync and no `SyncAsync`: ```csharp using Inngest.Connect; using Inngest.Testing.Containers; using Microsoft.Extensions.Hosting; var builder = Host.CreateApplicationBuilder(); builder.Services.AddSingleton(); builder.Services .AddInngest(options => { options.AppId = "shop-worker"; server.Configure(options); }) .AddFunction() .AddConnect(); using var worker = builder.Build(); await worker.StartAsync(); ``` Build the container with `new InngestServerBuilder("inngest/inngest:v1.49.0").WithConnectGateway()`. `ConnectGatewayPort` tells the port. The builder picks the port when it builds the container and keeps it from other processes until Docker has pulled the image and is about to publish it. A port that is taken by then can only be one a process took in that moment; if a start fails because the port is already allocated, build the container again, which picks another, or give `WithConnectGateway` a port of your own. The worker syncs through the gateway when it connects, which gives no signal either: an event sent before the app is registered starts no run. The worker takes work once `IInngestConnection.State` is `Active`, so the test waits for that: ```csharp using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; var connection = worker.Services.GetRequiredService(); var state = connection.State; while (state != InngestConnectionState.Active) { state = await connection.WaitForStateChangedAsync(state, cancellationToken); } ``` ## Apps that read their settings from the environment `Configure` is for apps that take an `Action`. For an app that reads the `INNGEST_*` variables, such as a process the test starts, `GetSdkEnvironment` lists the same settings as variables. A process inherits the variables of the machine, and the SDK reads some of them in an order that lets an inherited one win: `INNGEST_DEV=1` turns signature checks off, and `INNGEST_BASE_URL` can point at Inngest Cloud. So the list sets every variable that decides the mode and the addresses: `INNGEST_DEV` (the address of the dev server, or `0` for the self-hosted server), `INNGEST_BASE_URL`, `INNGEST_API_BASE_URL` and `INNGEST_EVENT_API_BASE_URL`, and for the self-hosted server `INNGEST_SIGNING_KEY` and `INNGEST_EVENT_KEY`. The names are those every Inngest SDK reads. Other `INNGEST_*` variables the process inherits still apply, such as `INNGEST_SERVE_ORIGIN` or `INNGEST_ENV`; remove the ones that matter from the process's environment. ```csharp using System.Diagnostics; using Inngest.Testing.Containers; var start = new ProcessStartInfo("dotnet", ["run", "--project", "src/Shop"]); foreach (var (name, value) in server.GetSdkEnvironment()) { start.Environment[name] = value; } ``` ## Options | Method of `InngestServerBuilder` | What it does | | - | - | | `WithSelfHosting()` | Runs `inngest start` in place of `inngest dev`. | | `WithSigningKey(key)`, `WithEventKey(key)` | Sets the keys of the self-hosted server instead of generating them. | | `WithAppUrl(url)` | Has the server sync the app at this address when it starts, and retry every second until the app answers. Once for each app. | | `WithConnectGateway(port)` | Publishes the Connect gateway on the same port inside the container and on the machine. Picks a free port without an argument. | | `WithFastTiming(false)` | Leaves the server's own timing. On by default: failed syncs are tried again after a second, failed steps are retried after a second instead of the server's backoff, and the queue is polled every 50 ms instead of every 150 ms. | | `WithCommand(...)` | Adds arguments after the ones the builder makes. | Everything else is the Testcontainers builder, such as `WithImagePullPolicy`, `WithNetwork` and `WithWaitStrategy`. The container is ready when the API answers `/health`, and the gateway `/ready` when it is published; a wait strategy with conditions of its own replaces that. | Member of `InngestServerContainer` | What it holds | | - | - | | `GetBaseAddress()` | The address of the server's API: events, the REST and GraphQL APIs, syncs and the health check. `GetConnectionString()` returns it as text. | | `Configure(options)` | Sets an app's `InngestOptions` to use the server. | | `GetSdkEnvironment()` | The same settings as `INNGEST_*` variables, with the ones an inherited variable could outrank. | | `SyncAsync(appUrl)` | Has the server register the app that listens at this address. | | `IsDevServer`, `SigningKey`, `EventKey` | Which server it is, and the keys of the self-hosted one; both keys are null for the dev server, which takes any event key. | | `ConnectGatewayPort` | The gateway's port, or null. | The dev server runs with `--no-discovery`, so that it does not scan ports for apps, and `--no-poll`, so that it syncs an app once instead of every few seconds. `inngest start` has neither flag and syncs only the apps it is told about. Both run with `DO_NOT_TRACK=1`. One more setting is the builder's to make: the server binds its Connect ports only after it has opened connections, and the kernel may give one of those connections such a port as its source port, which fails the start. The builder reserves the ports in the container, so the kernel never hands them out. ## What this does not do - The server keeps nothing between containers. Its runs, events and apps are gone when the container is, and parallel tests that share a container share its apps and events, so give each test its own event data to tell them apart. - It talks to Docker on the machine that runs the tests. A container reaches the app on that machine as `host.docker.internal`, which does not hold for a remote Docker host. - It waits for nothing the server does. A test that follows a run polls the REST or GraphQL API, or has the function signal the test as above; the server caches some REST answers for a number of seconds, so a poll that must be quick adds a unique query parameter to each request. --- # Native AOT and trimming > Publish an app with Inngest functions as a trimmed or Native AOT binary by giving the SDK source-generated JSON metadata for your types. Web page: https://damikun.github.io/inngest-dotnet/guides/native-aot/ The SDK is trimming and AOT compatible: its own types use source-generated `System.Text.Json` metadata and it does not rely on reflection. Your types are the only thing left to cover. Everything the SDK serializes for you, event data, step results, invoke payloads and function output, goes through `InngestOptions.SerializerOptions`, and in a trimmed or AOT app that needs a source-generated `JsonSerializerContext` that knows your types. ## Set it up Turn on Native AOT in the project: ```xml title="Shop.csproj" true true ``` Declare a context with every type that crosses the wire, and put it first in the resolver chain: ```csharp title="Program.cs" using System.Text.Json.Serialization; using Inngest; var builder = WebApplication.CreateSlimBuilder(args); builder.Services .AddInngest(options => { options.AppId = "shop"; options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJson.Default); }) .AddFunction(new FunctionOptions { Id = "greet", Triggers = [Trigger.Event("demo/greet")] }, async (context, cancellationToken) => { var reply = await context.Step.RunAsync("compose", () => new Reply($"Hello, {context.Event.Data.Name}!")); await context.Step.SleepAsync("pause", TimeSpan.FromSeconds(1)); return reply; }); var app = builder.Build(); app.MapInngest(); app.Run(); public sealed record Greeting(string Name); public sealed record Reply(string Text); [JsonSerializable(typeof(Greeting))] [JsonSerializable(typeof(Reply))] internal sealed partial class AppJson : JsonSerializerContext; ``` `samples/Inngest.AotSample` in the repository is this app. It publishes with no trimming or AOT warnings and runs against the dev server as a native binary: ```bash dotnet publish -c Release -r linux-x64 ``` ## Which types to list List every type that the SDK serializes or deserializes for your code: - the event data type of every function, `TEventData`; - the result type of every `RunAsync`, the `T` in `Task`; - the type each function returns as its output; - the `TData` of `WaitForEventAsync`, and the `TResult` and `data` of `InvokeAsync`; - the runtime types of `Data` and `User` in the events you send; - for each function that runs the chunks of a [fan-out](https://damikun.github.io/inngest-dotnet/guides/fan-out.md#choosing-the-sizes), `FanOutChunk` and `FanOutOutcome[]`. The methods return arrays, and the engine writes a function's output by its runtime type, so a function that returns what `MapAsync` gave lists `TResult[]`. The SDK covers its own public types, such as `FunctionFailure`, `SendEventsResult`, `JsonElement` and `object`, so a step without a result or an untyped event needs nothing extra. Anonymous types cannot be source generated, so use records where the other guides use `new { ... }`. A type the context does not know fails at run time with a `NotSupportedException` from `System.Text.Json`, in the step or call that serializes it; the publish warnings will not catch it, so test the paths that matter. ## Let the build find what is missing A forgotten type is the one thing the publish warnings do not catch. The `Inngest` package therefore includes an analyzer that runs in a project which turns reflection-based JSON off: `PublishAot` or `PublishTrimmed` set to `true`, or `JsonSerializerIsReflectionEnabledByDefault` set to `false`. It warns about every type of the list above that no context lists, for the event data and the result of your function classes, the event data of a delegate function and the types the steps save, such as the result of each `RunAsync`: ```text warning INNGEST010: 'Receipt' is the result of function 'OnRefund', but no JsonSerializerContext lists it with [JsonSerializable], and reflection-based JSON is turned off ``` In the IDE, a code fix adds the `[JsonSerializable]` attribute to your context. It is an analyzer and not a generator that fills the context, because the generator of `System.Text.Json` does not see code that another generator writes. [Source generator and analyzers](https://damikun.github.io/inngest-dotnet/guides/source-generator.md#check-the-json-of-a-native-aot-app) says what the analyzer checks and what it leaves to you, and covers the generator that registers the function classes. ## Webhooks [`MapInngestWebhook`](https://damikun.github.io/inngest-dotnet/guides/webhooks.md) needs nothing added to the context. The payload of a webhook is a `JsonElement`, which the SDK covers, so the event goes out as the provider sent it, and the verifiers use `System.Security.Cryptography` without reflection. What stays yours is the data type of the function that receives the event, as for any event, and the type a `Transform` puts in `Data` instead of the payload, such as a `JsonObject` with a header added. An app that maps endpoints for Stripe, GitHub, Shopify, Clerk and an HMAC provider publishes as a Native AOT binary without warnings, and verifies and sends their webhooks. `samples/Inngest.AotSample` maps a Stripe endpoint, so the publish in CI covers the package. ## Class functions and middleware `AddFunction` and `AddMiddleware` keep the constructors they need through `DynamicallyAccessedMembers`, so class-based functions and middleware work in AOT apps without extra configuration. `AddInngestFunctions()`, which the [source generator](https://damikun.github.io/inngest-dotnet/guides/source-generator.md) writes once a project sets `InngestGenerateFunctionRegistration` to `true`, calls `AddFunction` for each class that has `[InngestFunction]`, so the app lists no class and nothing scans for them. The AOT sample does this. Attribute classes work too: `AddFunction()` reads the attributes of the class it is given when the app starts, the generic ones such as `[EventTrigger]` included, which needs no reflection beyond what trimming keeps. Typed events need nothing either, since their names are static members. The [AOT sample](https://github.com/damikun/inngest-dotnet/tree/main/samples/Inngest.AotSample) registers an attribute class, sends it a typed event and invokes another function by its class, and publishes without warnings. Settings from a configuration section, `AddInngest(builder.Configuration.GetSection("Inngest"))`, are read key by key, not through the configuration binder, so they work in AOT apps as well. ## Packages that are not compatible `Inngest.EntityFrameworkCore` is the exception among the packages. Entity Framework Core is not trimming or Native AOT compatible, and the outbox runs on it, so the package does not declare itself compatible. Its code has no trim or AOT warning, and the events it stores are serialized from the same `InngestOptions.SerializerOptions` metadata as every other event, but an app that publishes with Native AOT cannot use it. See [Transactional outbox](https://damikun.github.io/inngest-dotnet/guides/outbox.md). Microsoft's [Native AOT deployment](https://learn.microsoft.com/dotnet/core/deploying/native-aot/) and [source generation](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/source-generation) docs cover the general rules. --- # Source generator and analyzers > Register every function class of a project with one generated call, and find the types a Native AOT app has not given its JsonSerializerContext while the project builds. Web page: https://damikun.github.io/inngest-dotnet/guides/source-generator/ The `Inngest` package carries a source generator, two analyzers and a code fix. The compiler loads them from the package, so there is nothing more to install: every project that references `Inngest`, directly or through `Inngest.AspNetCore` or `Inngest.Connect`, has them. They are built against Roslyn 4.8, the compiler of the first .NET 8 SDK and of Visual Studio 17.8, and run in that compiler and in every newer one. - The generator writes `AddInngestFunctions()`, which registers the function classes of the project. A project turns it on; until then it writes nothing, and the project builds as it did before. - An analyzer, with the generator, says why a class with `[InngestFunction]` is not registered. - The other analyzer, in an app that turns reflection-based JSON off, finds the types that no `JsonSerializerContext` lists. A code fix lists them. ## Register the function classes Turn the generator on in the project file: ```xml title="Shop.csproj" true ``` It is off by default so that a project which registers its classes by hand, or has a method of its own named `AddInngestFunctions`, is not changed by an upgrade. Then give each function class `[InngestFunction]`, and register all of them with one call: ```csharp title="Functions.cs" using Inngest; public sealed record OrderPlaced(string OrderId) : IInngestEventData { public static string EventName => "shop/order.placed"; } [InngestFunction("charge-order", Retries = 3)] [EventTrigger] public sealed class ChargeOrder : InngestFunction { public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) { await context.Step.RunAsync("charge", () => context.Logger.LogInformation("Charging {OrderId}", context.Event.Data.OrderId)); return null; } } [InngestFunction("send-receipt")] [EventTrigger] public sealed class SendReceipt : InngestFunction { public override Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) => Task.FromResult(null); } ``` ```csharp title="Program.cs" using Inngest; var builder = WebApplication.CreateBuilder(args); builder.Services .AddInngest(options => options.AppId = "shop") .AddInngestFunctions(); var app = builder.Build(); app.MapInngest(); app.Run(); ``` `AddInngestFunctions()` is the same as writing `AddFunction()` and `AddFunction()`, for every class. The compiler finds the classes while it builds the project, from the attribute, so there is no assembly scanning at run time and nothing for the trimmer to keep. What the generator writes for the project above, without the comments and the `GeneratedCode` attribute: ```csharp #pragma warning disable CS0612, CS0618 namespace Inngest { internal static class InngestFunctionRegistration_Shop { internal static global::Inngest.IInngestBuilder AddInngestFunctions(this global::Inngest.IInngestBuilder builder) { global::System.ArgumentNullException.ThrowIfNull(builder); global::Inngest.InngestBuilderExtensions.AddFunction(builder); global::Inngest.InngestBuilderExtensions.AddFunction(builder); return builder; } } } ``` The order of the calls is the order of the classes' names, so adding a file never reorders them. The method is in the `Inngest` namespace, with `AddFunction`, and it returns the builder, so more registrations can follow it. The file disables the warnings for obsolete code, so that a function class marked `[Obsolete]` can be registered without a warning in a file you cannot edit. `AddFunction()` still reads the attributes of each class when the app starts, as it does when you call it yourself. The generator decides which classes to register, not what their options are. ### Classes it leaves out The method registers a class that has `[InngestFunction]`, derives from `InngestFunction` or `InngestFunction`, and that generated code in the same project can create and name. A class that does not fit is left out, and the project builds with a warning on it that says why. These warnings are about the generated method, so a project that has not turned generation on gets none of them: a generic class it closes by hand, or a private class it registers from inside the class that holds it, is not a mistake there. | Diagnostic | The class | | - | - | | [`INNGEST001`](https://damikun.github.io/inngest-dotnet/guides/source-generator.md#inngest001) | has the attribute but is not a function: it does not derive from `InngestFunction` or `InngestFunction`. | | [`INNGEST002`](https://damikun.github.io/inngest-dotnet/guides/source-generator.md#inngest002) | is abstract. | | [`INNGEST003`](https://damikun.github.io/inngest-dotnet/guides/source-generator.md#inngest003) | is generic, or inside a generic class. | | [`INNGEST004`](https://damikun.github.io/inngest-dotnet/guides/source-generator.md#inngest004) | is private, `protected` or `private protected`, or file-local, or inside a type that is. A `protected internal` class is registered. | | [`INNGEST005`](https://damikun.github.io/inngest-dotnet/guides/source-generator.md#inngest005) | has no public constructor. | Public and internal classes are registered, nested ones too, in any namespace or none. A class registered in code with `FunctionOptions` needs no attribute, and so is not in the generated method. ### Several projects Each project that turns generation on gets a method of its own, for its own classes, in the namespace `Inngest`. The method is `internal` unless the project says otherwise, which keeps it from meeting the one in the project that references it, but two cases make a call ambiguous (`CS0121`), and the compiler says so: - A project that already has a method of its own called `AddInngestFunctions(this IInngestBuilder)`, such as a helper that registers its functions, meets the generated one as soon as it has a class with `[InngestFunction]`. - A project that another project sees the internals of, through `InternalsVisibleTo`, such as a library and the test project of the app, shares the library's internal method with it. The test project's own method is then ambiguous with the library's. `InngestFunctionsMethodName` renames the generated method, in either project. To register a library's functions in the app, give the library a name of its own and make the method public: ```xml title="Shop.Billing.csproj" true AddBillingFunctions public ``` ```csharp title="Program.cs" builder.Services .AddInngest(options => options.AppId = "shop") .AddInngestFunctions() .AddBillingFunctions(); ``` Two public methods with the same name in the same namespace are ambiguous in the same way, so a public method needs a name of its own. A library that keeps the default internal method can instead register its functions in a public method of its own: `public static IInngestBuilder AddBillingFunctions(this IInngestBuilder builder) => builder.AddInngestFunctions();`. The class that holds the method is named after the assembly. Characters an identifier cannot hold become underscores, and a name that had any also gets a short hash of the assembly's name, so that `Shop-Billing` and `Shop.Billing` do not become the same class. The MSBuild properties: | Property | Default | Meaning | | - | - | - | | `InngestGenerateFunctionRegistration` | `false` | `true` writes the method and reports the classes it leaves out. | | `InngestFunctionsMethodName` | `AddInngestFunctions` | The name of the generated method. It must be a C# identifier. | | `InngestFunctionsAccessibility` | `internal` | `public` or `internal`, for the class and the method. | | `DisableInngestAnalyzers` | `false` | `true` takes everything the package runs in the compiler out of the build: the generator and both analyzers. | A value the generator cannot use is reported as [`INNGEST006`](https://damikun.github.io/inngest-dotnet/guides/source-generator.md#inngest006), and the default is used. ### What it does not do - It registers the classes of the project it compiles, not those of the projects it references. Each of those has its own method, or none. - It leaves no class out on request. A class that has the attribute is registered by the method, and a second registration by hand fails when the app starts, because the function id is used twice. A project with a function that needs `AddFunction(configure)` does not turn generation on, and registers all its functions by hand. - It does not compute the options at compile time. They are read from the attributes when the app starts. ## Check the JSON of a Native AOT app An app that is published with Native AOT or trimmed has no reflection-based JSON, so every type that Inngest reads or writes for your functions must be listed by a source-generated `JsonSerializerContext`. A type that is not fails when the function runs, with a `NotSupportedException`. The [Native AOT guide](https://damikun.github.io/inngest-dotnet/guides/native-aot.md) explains the context. The analyzer finds the types that are missing while the project builds. It runs when the project sets `PublishAot` or `PublishTrimmed` to `true`, or `JsonSerializerIsReflectionEnabledByDefault` to `false`. A project that sets `JsonSerializerIsReflectionEnabledByDefault` to `true` is not checked. In any other project the analyzer does nothing. It reads the properties of the project it compiles, and a library does not publish: the function classes of a library that sets none of them are not checked, even when the app that uses the library is published with Native AOT. Set `JsonSerializerIsReflectionEnabledByDefault` to `false` in the library to have it checked. The property only matters to an app, so it changes nothing else in a library. It looks at: - the event data and the result type of every function class, that is, the `TEventData` and `TResult` of its base class; - the event data type of a function registered with `AddFunction(options, handler)`; - the type that a step saves with the run and reads back: the `T` of `RunAsync`, the input and result of `AIWrapAsync`, the data that `WaitForEventAsync` and `WaitForSignalAsync` wait for, the result of `InvokeAsync`, the response of `InferAsync` and the data `PublishAsync` publishes. A type counts as listed when a context lists it with `[JsonSerializable]`, or when a type that a context lists reaches it the way the generator of `System.Text.Json` does when it writes the metadata of that type: - through a public property or field, or one that is not public but has `[JsonInclude]`, unless it has `[JsonIgnore]`; a private, internal or protected member without `[JsonInclude]`, a static member and an explicit interface implementation are not part of the metadata; - through a parameter of the constructor it would call: the one marked `[JsonConstructor]`, or the only public one when there is no public constructor without parameters. The parameters of any other constructor are not reached; - as the element type of a collection, or a type argument of a framework generic such as `Dictionary`. A class that is a collection, such as `class Items : List`, is written as its elements, and its own members and constructors are not part of the metadata; - as a derived type named by `[JsonDerivedType]`. A type with a `[JsonConverter]` of its own is written by the converter, and nothing is reached through its members. The context can be in the project or in a project it references. The context that the SDK adds to the serializer options of every app is counted too, so `FunctionFailure`, `SendEventsResult`, `JsonElement`, `object` and what those hold, such as `string`, are never asked for. An anonymous type cannot be listed at all, so it gets a message of its own. The analyzer's idea of what is listed is checked against the generator of `System.Text.Json`, of .NET 8 and .NET 10, by the tests of this package. ```csharp title="Functions.cs" using System.Text.Json.Serialization; using Inngest; public sealed record Refund(string OrderId); public sealed record Receipt(string Id); [InngestFunction("refund")] public sealed class OnRefund : InngestFunction { public override Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) => Task.FromResult(new Receipt(context.Event.Data.OrderId)); } [JsonSerializable(typeof(Refund))] internal sealed partial class ShopJson : JsonSerializerContext; ``` With `PublishAot` on, the project above builds with a warning on `Receipt`: ```text warning INNGEST010: 'Receipt' is the result of function 'OnRefund', but no JsonSerializerContext lists it with [JsonSerializable], and reflection-based JSON is turned off ``` The code fix, in the IDE, adds `[JsonSerializable(typeof(Receipt))]` to a context of the project, and the `using` directive the type needs if the file lacks it. It offers one fix for each context the project has. Fixing all occurrences in a document, a project or a solution adds every missing type to the chosen context in one edit, each type once. It offers nothing when the project has no context: declare one first, as the Native AOT guide shows. ### Why the generator does not list the types itself The generator knows these types, so it could write the `[JsonSerializable]` attributes. It cannot: the generator of `System.Text.Json` reads the attributes of your source, and a source generator's output is not input to another generator. Code that Inngest's generator added to the context would not be seen. The code fix writes the attribute into your own file instead. ### What the analyzer does not check - Values whose type is only known at run time, such as an `object` that holds a record. That includes the data that `InvokeAsync` sends, the events your code sends and the body of an `InferAsync` request. List those types yourself, as the [Native AOT guide](https://damikun.github.io/inngest-dotnet/guides/native-aot.md#which-types-to-list) says. - Typed realtime topics, and publishing or subscribing through `IInngestRealtime`, which read their type through the same options. The data that a step publishes is checked. - Contexts that are built in code, and options whose resolver chain does not contain the context. Test the paths that matter after you publish. ## Diagnostics Every diagnostic has an id that `.editorconfig` can configure, such as `dotnet_diagnostic.INNGEST010.severity = none`, and `#pragma warning disable` works on the class or the call the diagnostic is on. `DisableInngestAnalyzers` removes them all, with the generator, for the case that one of them gets in the way of a build. ### INNGEST001 Warning. A class has `[InngestFunction]` but does not derive from `InngestFunction` or `InngestFunction`. The attribute does nothing on any other class: the class cannot be registered, by `AddFunction()` or by the generated method, and Inngest never runs it. Add the base class, or remove the attribute. Reported in a project that has turned generation on. ### INNGEST002 Warning. The class is abstract. The service provider cannot create it, and `[InngestFunction]` is not inherited, so the classes that derive from it are not registered either. Put the attribute on each concrete class. ### INNGEST003 Warning. The class is generic, or inside a generic class. The generator does not know which type arguments the app uses. Register the closed types with `AddFunction()`, or suppress the warning on a class that is only a template. ### INNGEST004 Warning. The class, or a type that contains it, is private, `protected`, `private protected` or file-local. The generated method is outside it. Make the class public or internal, with every type that contains it, or register it with `AddFunction()` from inside the containing type. ### INNGEST005 Warning. The class has no public constructor. The service provider calls public constructors only, so every call of the function would fail. Add one. ### INNGEST006 Warning. `InngestGenerateFunctionRegistration` is neither `true` nor `false`, `InngestFunctionsMethodName` is not a C# identifier, or `InngestFunctionsAccessibility` is neither `public` nor `internal`. The default is used. ### INNGEST010 Warning. A type that Inngest serializes for a function is missing from the `JsonSerializerContext`, in a project that turns reflection-based JSON off. Add `[JsonSerializable(typeof(...))]` for it to a context, which the code fix does. ### INNGEST011 Warning. The type is anonymous, which no context can list. Use a record, a class or a struct, and list it. --- # Deploying background jobs to production > Run an app with Inngest functions in production, in a container or anywhere ASP.NET Core runs, with cloud mode, keys, syncs and health checks. Web page: https://damikun.github.io/inngest-dotnet/guides/deploying/ An app with Inngest functions is an ordinary ASP.NET Core app with one extra endpoint. It deploys like any other, with three additions: it runs in cloud mode with its keys, Inngest must be able to reach its endpoint, and it has to be synced after each deploy. Figure: In production, Inngest calls the app's public endpoint with requests signed by the signing key, and any instance behind the load balancer can answer them. The app sends events with the event key, and reports its functions to Inngest when it syncs after a deploy. ## Checklist - **Cloud mode.** Do not set `INNGEST_DEV` in production. Cloud mode is the default. - **Keys.** Set `INNGEST_SIGNING_KEY` and `INNGEST_EVENT_KEY` from the Inngest dashboard, from your secret store. Without a signing key the endpoint answers Inngest with errors. See [Configuration](https://damikun.github.io/inngest-dotnet/guides/configuration.md). - **A public endpoint.** Inngest calls `/api/inngest` over HTTPS. Behind a proxy or load balancer, set `INNGEST_SERVE_ORIGIN` to the public origin so that syncs report the right URL. If the app cannot be reachable, use [Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md) instead. - **A sync after every deploy.** See below. - **A readiness check.** Point the probe at a path of its own, and add the check that tells whether the app can work with Inngest. See [Health checks](https://damikun.github.io/inngest-dotnet/guides/deploying.md#health-checks). - **Time for long steps.** A step runs inside one request from Inngest, so proxy and platform request timeouts apply to it. Keep steps well under them, split long work into several steps, or use Connect. ## Syncing Inngest only knows about the functions the app reported at its last sync. The first time, add the app in the Inngest dashboard with its URL; Inngest syncs it in-band, with a signed request that the SDK answers with its functions. After later deploys, sync again from the deploy pipeline: ```bash curl -X PUT https://shop.example.com/api/inngest ``` That `PUT` makes the SDK post its configuration to Inngest, authenticated with the signing key. Inngest's [apps and syncs](https://www.inngest.com/docs/platform-and-operations/apps-and-syncs) page describes both kinds and the dashboard view of them. ## In a container A multi-stage build is all it takes. The image below runs as the non-root user of the .NET images and listens on port 8080: ```dockerfile title="Dockerfile" FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY . . RUN dotnet publish Shop/Shop.csproj -c Release -o /app FROM mcr.microsoft.com/dotnet/aspnet:10.0 WORKDIR /app COPY --from=build /app . USER $APP_UID EXPOSE 8080 ENTRYPOINT ["dotnet", "Shop.dll"] ``` For a [Native AOT](https://damikun.github.io/inngest-dotnet/guides/native-aot.md) app, publish with `-r linux-x64` and use `mcr.microsoft.com/dotnet/runtime-deps:10.0` as the final image. Run it with the keys as environment variables: ```bash docker run -p 8080:8080 -e INNGEST_SIGNING_KEY=signkey-prod-... -e INNGEST_EVENT_KEY=... -e INNGEST_SERVE_ORIGIN=https://shop.example.com shop:latest ``` ## In Azure Functions An Azure Functions app serves the endpoint from an HTTP-triggered function of the isolated worker, and has its own limit on how long one call can take. See [Azure Functions](https://damikun.github.io/inngest-dotnet/guides/azure-functions.md). ## Health checks Point health checks at an endpoint of their own, not at `/api/inngest`: in cloud mode the SDK answers unsigned requests there with `401 Unauthorized`, so a probe of it never passes. A liveness endpoint that only shows the process is up is one line: ```csharp app.MapGet("/healthz", () => Results.Ok()); ``` ### Whether the app can work with Inngest `AddInngest` on the health checks builder adds a check for an app that serves its functions with `MapInngest`. It is meant for the readiness probe: tag it, serve the tagged checks on a path of their own, and keep it out of the liveness probe, because restarting the app does not bring Inngest back. ```csharp builder.Services.AddHealthChecks().AddInngest(tags: ["ready"]); ``` ```csharp using Microsoft.AspNetCore.Diagnostics.HealthChecks; app.MapHealthChecks("/healthz/ready", new HealthCheckOptions { Predicate = check => check.Tags.Contains("ready"), }); app.MapHealthChecks("/healthz/live", new HealthCheckOptions { Predicate = _ => false, }); ``` ASP.NET Core answers `200` for a healthy or degraded result and `503` for an unhealthy one, which is what a probe reads. In Kubernetes the two paths go into the probes of the container: ```yaml readinessProbe: httpGet: { path: /healthz/ready, port: 8080 } periodSeconds: 10 timeoutSeconds: 5 livenessProbe: httpGet: { path: /healthz/live, port: 8080 } ``` The check reports what it found as entries in the data of its result, and names the first problem in the description. It checks two things: | Part | Healthy when | Entries in the data | | - | - | - | | Configuration | In cloud mode, a signing key is set. With `RequireEventKey`, an event key is set too; an app that only receives calls from Inngest needs none. | `signingKey`, `signingKeyFallback` and `eventKey`: `set`, `not set` or `missing` | | Inngest | Inngest answers a request with a `2xx`. In cloud mode the request carries the signing key, so a key that Inngest does not know fails it; if Inngest refuses the signing key, the fallback key is tried, as for every call to the API. | `mode`, `apiBaseUrl`, `api` (`ok`, `refused`, `unreachable`, `timed out`, `rate limited` or `unexpected response`), `apiStatusCode`, `acceptedKey` (`signingKey` or `signingKeyFallback`) and `checkedAt` | In cloud mode, and against a self-hosted server, the request is `GET /v2/apps?limit=1` on the API address (`https://api.inngest.com`, or `INNGEST_BASE_URL`). It lists at most one app and changes nothing. A self-hosted server needs Inngest v1.40.0 or later for it. The dev server holds no keys, so in dev mode the request is `GET /health`, its own health endpoint. The result never shows a key, the hashed token the SDK sends in its place, or the event key; the data says whether each is set. Nor does the address: only its scheme, host and port. A missing key fails the check without a request. A failed check reports `failureStatus`, `Unhealthy` unless you give another. Choose it for what an outage of Inngest should do to the app. `Unhealthy` takes the instance out of rotation, which suits an app that exists to run Inngest functions. An app that mostly serves its own users, and sends work to Inngest on the side, should stay in rotation: use `HealthStatus.Degraded`, which still answers `200` and still shows the problem in the report. When Inngest rate limits the request (`429`), the result is `Degraded` whatever `failureStatus` says, because the request proves nothing about the key, and taking every replica out of rotation because the probes ask too often would be an outage the probes made. ### Cost and timeouts Every probe could send a request to Inngest, and a load balancer, a kubelet and a monitor may probe every few seconds, on every replica. The check keeps Inngest's answer instead: for `CacheDuration`, 30 seconds by default, and for `FailureCacheDuration`, 5 seconds, when the answer was a failure, so that an instance that was not ready is back in rotation soon after Inngest is reachable again. A rate limited answer is kept for the full `CacheDuration`. Probes that arrive while a request is out wait for it instead of sending their own. Each registration and each replica keeps its own answer, so N replicas send about N requests per interval. ```csharp using Microsoft.Extensions.Diagnostics.HealthChecks; builder.Services.AddHealthChecks().AddInngest( options => { options.RequireEventKey = true; options.CacheDuration = TimeSpan.FromMinutes(1); }, failureStatus: HealthStatus.Degraded, tags: ["ready"], timeout: TimeSpan.FromSeconds(5)); ``` A probe never waits on Inngest for long. The request is cancelled when the probe is, for example when the kubelet's `timeoutSeconds` has passed, and when the `timeout` of the check has, which is 10 seconds unless you give one. Give the probe itself a few seconds: the first request after a start opens a connection to Inngest. ### What it does not check - **Whether Inngest can reach the app.** The check runs inside the app and calls Inngest, not the other way round. A firewall, a wrong `INNGEST_SERVE_ORIGIN` or a proxy that blocks Inngest shows up as failed syncs and calls in the Inngest dashboard. - **Whether the event key is valid.** Only that it is set. Finding out whether Inngest accepts it would take a request to the Event API, which the check does not send, and the address events go to is not probed either. - **Whether the app is synced**, unless you ask for it, as below. ### Whether the app is synced Inngest keeps the functions an app reported at its last sync, and its Get app endpoint, which Inngest marks Beta, returns that record. With `CheckSync` the check asks for the app by its id, in addition to the request above, and fails while - Inngest has no app by that id, - the app is archived, - its latest sync failed, by the status of the sync or, as the dev server reports it, by an error, - Inngest synced another `AppVersion` than the one this instance has, when both have one, or - Inngest knows another number of functions than this instance serves. The data then holds `sync` (`synced`, `not registered`, `archived`, `failed`, `version mismatch` or `function count mismatch`), `functionsServed` and, as far as Inngest reports them, `functionsInInngest`, `syncStatus`, `syncedAt`, `appVersion` and `syncedAppVersion`. The endpoint is in Inngest v1.27.1 and later. The check compares counts and a version, not function ids or settings: a renamed function keeps the count. Set `AppVersion`, to a build number or a commit, and every deploy is compared. A flag kept in the process could not answer this question at all, because Inngest syncs through whichever instance the load balancer picks, so what one instance remembers says nothing about the others; this check asks Inngest. Do not use it for the readiness of a rollout. The sync usually comes after the new instances serve, as [above](https://damikun.github.io/inngest-dotnet/guides/deploying.md#syncing), so a deploy that changes the functions fails the check until the sync, and a rollout that waits for readiness would wait for a sync that only comes after it. Give the check a registration of its own, with its own name and tag, for monitoring or for a step of the pipeline after the sync: ```csharp using Microsoft.Extensions.Diagnostics.HealthChecks; builder.Services.AddHealthChecks() .AddInngest(tags: ["ready"]) .AddInngest(options => options.CheckSync = true, "inngest-sync", HealthStatus.Degraded, ["sync"]); ``` ### Connect workers A Connect worker has no endpoint for Inngest, but it can still report whether it is connected. The Connect health check is healthy only while the worker holds an active connection, so use it for the readiness probe, not for liveness: a worker that is reconnecting recovers by itself. ```csharp builder.Services.AddHealthChecks().AddInngestConnect(); ``` ## Scaling and restarts The app holds no state between calls: a run's state lives in Inngest, and any instance can serve any call, including the next step of a run another instance started. Run as many replicas as you need behind a load balancer. A call that is cut off by a restart or a crash is retried by Inngest, so let the host shut down gracefully but do not worry about losing runs. A load balancer that closes idle connections cuts off a call that runs longer than its timeout, and Inngest retries the call from the step it was running. Keep each step shorter than the timeout, or turn on [streaming](https://damikun.github.io/inngest-dotnet/guides/configuration.md#behind-a-proxy), which keeps a long call's connection busy. ## On AWS Lambda A Lambda function can serve the endpoint, behind an API Gateway REST API or HTTP API or a function URL, as a managed runtime function or a Native AOT executable. `Inngest.AwsLambda` handles the events of all three, and the timeouts of API Gateway apply to the steps it runs. [Running on AWS Lambda](https://damikun.github.io/inngest-dotnet/guides/aws-lambda.md) covers the setup and the limits. ## Self-hosting Inngest To run Inngest yourself instead of using Inngest Cloud, point the SDK at your server with `INNGEST_BASE_URL` and use the keys your server is configured with. See [self-hosting](https://www.inngest.com/docs/platform-and-operations/self-host-inngest) in the Inngest docs. ## Related examples The [Connect worker](https://damikun.github.io/inngest-dotnet/examples/connect-worker.md) example includes Dockerfiles for a worker and a web app. --- # 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 { public override async Task ExecuteAsync(FunctionContext 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(); _inngest = new InngestLambdaHandler(services.BuildServiceProvider()); } [LambdaSerializer(typeof(DefaultLambdaJsonSerializer))] public Task 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" Exe net10.0 bootstrap true true ``` ```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(); await using var provider = services.BuildServiceProvider(); var inngest = new InngestLambdaHandler(provider); Func> handler = inngest.HandleAsync; await LambdaBootstrapBuilder .Create(handler, new SourceGeneratorLambdaJsonSerializer()) .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(); 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` 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. --- # Inngest in Azure Functions > Serve the Inngest endpoint from an HTTP-triggered function of an Azure Functions app in the isolated worker model, run it locally with func start, and work within the 230-second response limit. Web page: https://damikun.github.io/inngest-dotnet/guides/azure-functions/ `Inngest.AzureFunctions` serves the Inngest endpoint from an HTTP-triggered function of an Azure Functions app in the [isolated worker model](https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide). 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](https://damikun.github.io/inngest-dotnet/guides/azure-functions.md#the-230-second-limit). ## Set it up 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`: ```bash 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](https://damikun.github.io/inngest-dotnet/guides/getting-started.md#2-write-a-function). It is resolved from the services of the invocation, so it can take what you registered: ```csharp title="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 logger) : InngestFunction { public override async Task ExecuteAsync(FunctionContext 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. ```csharp title="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(); 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](https://damikun.github.io/inngest-dotnet/guides/azure-functions.md#the-two-http-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: ```csharp title="InngestEndpoint.cs" using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; public sealed class InngestEndpoint { [Function("inngest")] public Task 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](https://damikun.github.io/inngest-dotnet/guides/azure-functions.md#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](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-http-webhook-trigger). - **`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. ## Run it locally 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: ```json title="local.settings.json" { "IsEncrypted": false, "Values": { "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", "INNGEST_DEV": "1" } } ``` Start the app with [Azure Functions Core Tools](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local), which listens on port 7071 unless told otherwise: ```bash func start ``` Start the [Inngest dev server](https://www.inngest.com/docs/local-development) and point it at the function, with npx, or with Docker when Node.js is not installed: ```bash 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. ## Run it in Azure 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](https://damikun.github.io/inngest-dotnet/guides/deploying.md#syncing) 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. ### The URL Inngest calls 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`: ```csharp 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`: ```csharp 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](https://damikun.github.io/inngest-dotnet/guides/configuration.md#syncs) and [Behind a proxy](https://damikun.github.io/inngest-dotnet/guides/configuration.md#behind-a-proxy). The other settings are the same as in any app: [Configuration](https://damikun.github.io/inngest-dotnet/guides/configuration.md). ## The 230-second limit 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](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-http-webhook-trigger#limits) 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](https://damikun.github.io/inngest-dotnet/guides/sleep-and-wait-for-events.md) that the worker sends when it is done. [Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md) 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`: ```csharp options.Checkpointing = new CheckpointingOptions { MaxRuntime = TimeSpan.FromSeconds(30) }; ``` ### Streaming 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](https://damikun.github.io/inngest-dotnet/guides/configuration.md#behind-a-proxy)): ```csharp 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](https://azure.microsoft.com/updates/generally-available-aspnet-core-integration-in-azure-functions/) 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. ## The two HTTP models | | 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. ## What is not supported - **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](https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide#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](https://damikun.github.io/inngest-dotnet/guides/native-aot.md). - **.NET Framework.** The package targets `net8.0` and `net10.0`, like the others. ## What has been tested 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. --- # Order fulfillment workflow in C# > Reserve stock, charge the card, ship and confirm an order, release the stock when payment fails, cancel on request and alert someone when a run fails for good. Web page: https://damikun.github.io/inngest-dotnet/examples/order-fulfillment/ An order goes through four steps that each talk to another system. Any of them can fail on its own; none of them should run twice. Each is a step, so a retry repeats only the step that failed. ```csharp title="FulfillOrder.cs" using Inngest; public sealed record OrderPlaced(string OrderId, string CustomerId, decimal Total, string Email) : IInngestEventData { public static string EventName => "shop/order.placed"; } public sealed record OrderCancelled(string OrderId) : IInngestEventData { public static string EventName => "shop/order.cancelled"; } public sealed record Shipment(string TrackingNumber, string Carrier); public sealed class FulfillOrder(IInventory inventory, IPayments payments, IShipping shipping, IMailer mailer, IAlerts alerts) : InngestFunction, IFailureHandler { public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) { var order = context.Event.Data; var reservationId = await context.Step.RunAsync("reserve-stock", ct => inventory.ReserveAsync(order.OrderId, ct)); string paymentId; try { // The order id is the payment provider's idempotency key, so a retry after a lost response // cannot charge the card twice. paymentId = await context.Step.RunAsync("charge-card", ct => payments.ChargeAsync(order.CustomerId, order.Total, idempotencyKey: order.OrderId, ct)); } catch (StepFailedException exception) { await context.Step.RunAsync("release-stock", ct => inventory.ReleaseAsync(reservationId, ct)); await context.Step.RunAsync("send-payment-failed", ct => mailer.SendAsync(order.Email, "We could not take your payment", ct)); throw new NonRetriableException($"Payment for {order.OrderId} failed: {exception.Error.Message}", exception); } var shipment = await context.Step.RunAsync("create-shipment", ct => shipping.CreateAsync(order.OrderId, reservationId, ct)); await context.Step.RunAsync("send-confirmation", ct => mailer.SendAsync(order.Email, $"On its way with {shipment.Carrier}: {shipment.TrackingNumber}", ct)); return new { order.OrderId, paymentId, shipment.TrackingNumber }; } public async Task OnFailureAsync(FunctionContext context, CancellationToken cancellationToken) { var failure = context.Event.Data; await context.Step.RunAsync("alert-operations", ct => alerts.SendAsync($"Order run {failure.RunId} failed: {failure.Error.Name}: {failure.Error.Message}", ct)); } } public interface IInventory { Task ReserveAsync(string orderId, CancellationToken cancellationToken); Task ReleaseAsync(string reservationId, CancellationToken cancellationToken); } public interface IPayments { Task ChargeAsync(string customerId, decimal amount, string idempotencyKey, CancellationToken cancellationToken); } public interface IShipping { Task CreateAsync(string orderId, string reservationId, CancellationToken cancellationToken); } public interface IMailer { Task SendAsync(string to, string subject, CancellationToken cancellationToken); } public interface IAlerts { Task SendAsync(string text, CancellationToken cancellationToken); } ``` The app registers the function with its options as attributes on the class, or with the options in code: ## Attributes ```csharp [InngestFunction("fulfill-order", Name = "Fulfill an order", Retries = 5, Idempotency = "event.data.orderId")] [EventTrigger] [Concurrency(1, Key = "event.data.customerId")] [CancelOn(Match = "data.orderId", Timeout = "1h")] ``` The attributes go on `FulfillOrder`, and the app registers it without options: ```csharp title="Program.cs" using Inngest; var builder = WebApplication.CreateBuilder(args); // Register the implementations of IInventory, IPayments, IShipping, IMailer and IAlerts here. builder.Services .AddInngest(options => options.AppId = "shop") .AddFunction(); var app = builder.Build(); app.MapInngest("/api/inngest"); app.Run(); ``` ## Code first ```csharp title="Program.cs" using Inngest; var builder = WebApplication.CreateBuilder(args); // Register the implementations of IInventory, IPayments, IShipping, IMailer and IAlerts here. builder.Services .AddInngest(options => options.AppId = "shop") .AddFunction(new FunctionOptions { Id = "fulfill-order", Name = "Fulfill an order", Triggers = [Trigger.Event()], Retries = 5, Concurrency = [new ConcurrencyLimit(1) { Key = "event.data.customerId" }], CancelOn = [CancelOn.For() with { Match = "data.orderId", Timeout = TimeSpan.FromHours(1) }], Idempotency = "event.data.orderId", }); var app = builder.Build(); app.MapInngest("/api/inngest"); app.Run(); ``` ## How it works - **One step per side effect.** Reserving, charging, shipping and emailing are separate steps. If the shipping API is down, Inngest retries `create-shipment` up to five times while the reservation and the charge stay done. - **Compensation.** When `charge-card` fails all its attempts, the function catches `StepFailedException`, releases the stock and tells the customer, both as steps, then fails the run with `NonRetriableException`, since retrying the run would not change the outcome. - **No double charges.** The charge passes the order id to the payment provider as an idempotency key. A step can succeed and then lose its response, so the provider must recognize a repeat; Inngest cannot. `Idempotency` on the function keeps a duplicated `shop/order.placed` event from starting a second run within 24 hours. - **One order per customer at a time.** The concurrency limit keyed on the customer stops two orders of one customer from competing for the same stock, while orders of different customers run side by side. - **Cancellation.** A `shop/order.cancelled` event for the same order, within an hour, stops the run before its next step. - **Alerting.** `IFailureHandler` runs once, after the run has failed for good, for example when a step keeps failing. The compensation path ends in that handler too, so operations hear about every failed order. Try it with the dev server by sending `shop/order.placed` with `orderId`, `customerId`, `total` and `email`. See [Errors and retries](https://damikun.github.io/inngest-dotnet/guides/errors-and-retries.md) and [Flow control](https://damikun.github.io/inngest-dotnet/guides/flow-control.md) for the options used here. --- # User onboarding with waits and reminders > Welcome a new user, wait up to three days for them to activate their account, remind them if they do not, and stop when the account is deleted. Web page: https://damikun.github.io/inngest-dotnet/examples/user-onboarding/ Onboarding mixes waiting and reacting: send a welcome email, wait for the user to activate, nudge them if they do not, and follow up a day after they do. The function sleeps and waits through all of it without holding anything. ```csharp title="OnboardUser.cs" using Inngest; public sealed record UserSignedUp(string UserId, string Email, string Name) : IInngestEventData { public static string EventName => "user/signed-up"; } public sealed record UserActivated(string UserId, string Plan) : IInngestEventData { public static string EventName => "user/activated"; } public sealed record UserDeleted(string UserId) : IInngestEventData { public static string EventName => "user/deleted"; } public sealed class OnboardUser(IMailer mailer, ICrm crm) : InngestFunction { public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) { var user = context.Event.Data; await context.Step.RunAsync("send-welcome", ct => mailer.SendAsync(user.Email, "welcome", new { user.Name }, ct)); var activated = await context.Step.WaitForEventAsync("wait-for-activation", TimeSpan.FromDays(3), match: "data.userId"); if (activated is null) { await context.Step.RunAsync("send-reminder", ct => mailer.SendAsync(user.Email, "activation-reminder", new { user.Name }, ct)); activated = await context.Step.WaitForEventAsync("wait-after-reminder", TimeSpan.FromDays(4), match: "data.userId"); if (activated is null) { await context.Step.RunAsync("mark-dormant", ct => crm.MarkDormantAsync(user.UserId, ct)); return new { user.UserId, activated = false }; } } await context.Step.SleepAsync("wait-a-day", TimeSpan.FromDays(1)); var template = activated.Data.Plan == "team" ? "tips-for-teams" : "tips"; await context.Step.RunAsync("send-tips", ct => mailer.SendAsync(user.Email, template, new { user.Name }, ct)); return new { user.UserId, activated = true }; } } public interface IMailer { Task SendAsync(string to, string template, object model, CancellationToken cancellationToken); } public interface ICrm { Task MarkDormantAsync(string userId, CancellationToken cancellationToken); } ``` Each event record names its event in `EventName`, so the trigger, the waits and the cancellation refer to the type. The function's options give it its id, trigger, cancellation and rate limit, as attributes on the class or in code: ## Attributes ```csharp [InngestFunction("onboard-user")] [EventTrigger] [CancelOn(Match = "data.userId")] [RateLimit(1, "24h", Key = "event.data.userId")] ``` The attributes go on `OnboardUser`, and `AddFunction` reads them once at startup: ```csharp title="Program.cs (registration)" inngest.AddFunction(); ``` ## Code first ```csharp title="Program.cs (registration)" inngest.AddFunction(new FunctionOptions { Id = "onboard-user", Triggers = [Trigger.Event()], CancelOn = [CancelOn.For() with { Match = "data.userId" }], RateLimit = new RateLimit(1, TimeSpan.FromHours(24)) { Key = "event.data.userId" }, }); ``` When the user confirms their email, the app sends a `UserActivated` event, named `user/activated`, with the same `userId`: ```csharp await inngest.SendAsync(new UserActivated(userId, plan), cancellationToken); ``` ## How it works - **Waiting for a person.** `WaitForEventAsync` pauses the run for up to three days. `match: "data.userId"` makes only this user's activation resume it. On timeout it returns `null`, and the function sends a reminder and waits again. - **Branching on what happened.** The activation event is a step result, so code after the wait sees the same event on every call, and the plan decides which tips to send. - **Stopping early.** The cancellation on `UserDeleted` ends the run when the account is deleted, even in the middle of a three-day wait. - **One onboarding per user.** The rate limit skips a second `user/signed-up` for the same user within a day, for example from a double-clicked sign-up button. A wait only sees events that arrive after it starts. Here the welcome email goes out first, which takes a moment; see [Sleep and wait for events](https://damikun.github.io/inngest-dotnet/guides/sleep-and-wait-for-events.md#only-events-after-the-wait-starts) if your users can activate faster than that. --- # Nightly billing with cron and fan-out > A cron function that finds every account due for billing and fans out one event per account, and a second function that bills each account with its own retries and limits. Web page: https://damikun.github.io/inngest-dotnet/examples/nightly-billing/ Billing thousands of accounts in one loop has two problems: one failing account stops or delays the rest, and a retry would bill the early ones again. Splitting the work fixes both. A scheduled function only decides what to do and sends one event per account; a second function bills one account per run. ```csharp title="StartBillingRun.cs" using System.Text.Json; using Inngest; public sealed record AccountDue(string AccountId, string BillingDate) : IInngestEventData { public static string EventName => "billing/account.due"; } public sealed class StartBillingRun(IAccounts accounts, TimeProvider time) : InngestFunction { private const int ChunkSize = 500; public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) { // Both values come from steps, so every call of this run sees the same date and the same list. var billingDate = await context.Step.RunAsync("pick-billing-date", () => DateOnly.FromDateTime(time.GetUtcNow().UtcDateTime).ToString("yyyy-MM-dd")); var due = await context.Step.RunAsync("load-accounts-due", ct => accounts.GetDueAsync(DateOnly.Parse(billingDate), ct)); var chunks = due.Chunk(ChunkSize).ToArray(); for (var i = 0; i < chunks.Length; i++) { InngestEvent[] events = [ .. chunks[i].Select(accountId => new InngestEvent(AccountDue.EventName, new AccountDue(accountId, billingDate)) { // One event per account and day, however often this function runs. Id = $"billing-{billingDate}-{accountId}", }), ]; await context.Step.SendEventAsync($"send-batch-{i}", events); } return new { billingDate, accounts = due.Count }; } } public interface IAccounts { Task> GetDueAsync(DateOnly date, CancellationToken cancellationToken); } ``` ```csharp title="BillAccount.cs" using Inngest; public sealed record Bill(string InvoiceId, decimal Amount); public sealed class BillAccount(IBillingEngine billing) : InngestFunction { public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) { var (accountId, billingDate) = context.Event.Data; var bill = await context.Step.RunAsync("create-invoice", ct => billing.CreateInvoiceAsync(accountId, billingDate, ct)); if (bill.Amount == 0) { return new { accountId, charged = false }; } await context.Step.RunAsync("collect-payment", ct => billing.CollectAsync(bill.InvoiceId, ct)); await context.Step.RunAsync("email-invoice", ct => billing.EmailAsync(bill.InvoiceId, ct)); return new { accountId, bill.InvoiceId, charged = true }; } } public interface IBillingEngine { Task CreateInvoiceAsync(string accountId, string billingDate, CancellationToken cancellationToken); Task CollectAsync(string invoiceId, CancellationToken cancellationToken); Task EmailAsync(string invoiceId, CancellationToken cancellationToken); } ``` The app registers both with their options as attributes on the classes, or with the options in code: ## Attributes On `StartBillingRun`: ```csharp [InngestFunction("start-billing-run")] [CronTrigger("0 2 * * *", Timezone = "Europe/Prague")] [Singleton(SingletonMode.Skip)] ``` On `BillAccount`, with durations as time strings such as `"1m"`: ```csharp [InngestFunction("bill-account", Retries = 10, Idempotency = "event.data.accountId + '-' + event.data.billingDate")] [EventTrigger] [Concurrency(20)] [Throttle(200, "1m")] ``` `AddFunction` reads the attributes of both classes once at startup: ```csharp title="Program.cs (registration)" inngest .AddFunction() .AddFunction(); ``` ## Code first ```csharp title="Program.cs (registration)" inngest .AddFunction(new FunctionOptions { Id = "start-billing-run", Triggers = [Trigger.Cron("0 2 * * *", timezone: "Europe/Prague")], Singleton = new Singleton(SingletonMode.Skip), }) .AddFunction(new FunctionOptions { Id = "bill-account", Triggers = [Trigger.Event()], Retries = 10, Concurrency = [new ConcurrencyLimit(20)], Throttle = new Throttle(200, TimeSpan.FromMinutes(1)), Idempotency = "event.data.accountId + '-' + event.data.billingDate", }); ``` `TimeProvider` is registered by `AddInngest` if the app has not registered one. ## How it works - **Decide once, in steps.** The billing date and the list of accounts come from steps. If sending a batch fails and the function is retried, it sends the same accounts for the same date instead of reading the clock and the database again. - **Fan out with events.** Each batch of up to 500 events goes out in one `SendEventAsync` step. The events are built as `InngestEvent` so that each carries an id derived from the account and the date, and a repeated run produces duplicates that Inngest ignores. Inngest accepts up to 5,000 events per request. - **One run per account.** `BillAccount` runs once per event, with its own ten retries. A failing account does not hold up the others, and its failure is visible on its own. - **Limits on the downstream system.** The concurrency limit bills at most 20 accounts at once and the throttle starts at most 200 per minute, whatever the size of the batch. `Idempotency` stops a second run for the same account and date within a day. - **No overlap.** The singleton option skips a scheduled run while the previous one is still going. The scheduled run's own event is `inngest/scheduled.timer`, which is why `StartBillingRun` reads its event as `JsonElement` and ignores it. --- # Fan-out with invoke and Task.WhenAll > Build a weekly report from per-region figures by invoking one function per region in parallel, and finish the report even when a region fails. Web page: https://damikun.github.io/inngest-dotnet/examples/fan-out-with-invoke/ When a function needs results back from many pieces of work, invoke a function per piece. Each invocation is a run of its own with its own retries and concurrency, and the caller waits for all of them with `Task.WhenAll`. ```csharp title="RegionFigures.cs" using Inngest; public sealed record RegionRequest(string Region, string Week); public sealed record RegionFigures(string Region, decimal Revenue, int Orders); // Has no trigger of its own: other functions invoke it. public sealed class ComputeRegionFigures(IWarehouseQueries queries) : InngestFunction { public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) { var (region, week) = context.Event.Data; var revenue = await context.Step.RunAsync("revenue", ct => queries.RevenueAsync(region, week, ct)); var orders = await context.Step.RunAsync("orders", ct => queries.OrderCountAsync(region, week, ct)); return new RegionFigures(region, revenue, orders); } } public interface IWarehouseQueries { Task RevenueAsync(string region, string week, CancellationToken cancellationToken); Task OrderCountAsync(string region, string week, CancellationToken cancellationToken); } ``` ```csharp title="WeeklyReport.cs" using Inngest; public sealed record ReportRequested(string Week) : IInngestEventData { public static string EventName => "reports/weekly.requested"; } public sealed class WeeklyReport(IReportMailer mailer) : InngestFunction { private static readonly string[] Regions = ["eu-west", "eu-central", "us-east", "us-west", "ap-south"]; public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) { var week = context.Event.Data.Week; var results = await Task.WhenAll(Regions.Select(region => FiguresOrNullAsync(context, region, week))); var figures = results.OfType().ToList(); var missing = Regions.Except(figures.Select(f => f.Region)).ToList(); await context.Step.RunAsync("send-report", ct => mailer.SendAsync(week, figures, missing, ct)); return new { week, regions = figures.Count, missing }; } // A region that fails all its attempts, or does not finish within the timeout, is reported as missing // instead of failing the whole report. private static async Task FiguresOrNullAsync(FunctionContext context, string region, string week) { try { return await context.Step.InvokeAsync($"figures-{region}", new RegionRequest(region, week), timeout: TimeSpan.FromMinutes(30)); } catch (StepFailedException exception) { context.Logger.LogWarning("No figures for {Region}: {Error}", region, exception.Error.Message); return null; } } } public interface IReportMailer { Task SendAsync(string week, IReadOnlyList figures, IReadOnlyList missing, CancellationToken cancellationToken); } ``` The app registers both with their options as attributes on the classes, or with the options in code: ## Attributes On `ComputeRegionFigures`, which has no trigger attribute: ```csharp [InngestFunction("compute-region-figures", Retries = 3)] [Concurrency(2)] ``` On `WeeklyReport`: ```csharp [InngestFunction("weekly-report")] [EventTrigger] ``` `AddFunction` reads the attributes of both classes once at startup: ```csharp title="Program.cs (registration)" inngest .AddFunction() .AddFunction(); ``` ## Code first ```csharp title="Program.cs (registration)" inngest .AddFunction(new FunctionOptions { Id = "weekly-report", Triggers = [Trigger.Event()], }) .AddFunction(new FunctionOptions { Id = "compute-region-figures", Triggers = [], Retries = 3, Concurrency = [new ConcurrencyLimit(2)], }); ``` ## How it works - **Parallel invocations.** All five `InvokeAsync` steps start before any is awaited, so the SDK reports them together and Inngest starts five runs of `compute-region-figures`. The concurrency limit lets two of them query the warehouse at once. - **Results come back typed.** `ComputeRegionFigures` derives from `InngestFunction`, so `InvokeAsync` returns a `RegionFigures` and the compiler checks the result type. The step finds the function's id by its class, so the id is written once, where the function is registered or in its attribute. To invoke a function of another app, or a delegate function, pass `InvokeOptions` with its id instead. - **Partial failure.** An invoke step fails when the invoked run fails for good or the timeout passes. Catching `StepFailedException` per region turns that into a gap in the report rather than a failed report. The helper takes the base `FunctionContext`, which has the step tools and the logger. - **Retries stay where the work is.** The region function retries its own queries. The invoke step itself is not retried, so retries never multiply across the two levels. [Invoking functions](https://damikun.github.io/inngest-dotnet/guides/invoking-functions.md) covers the options, and [Parallel steps](https://damikun.github.io/inngest-dotnet/guides/parallel-steps.md) how parallel steps execute. --- # Webhook processing with idempotency > Accept payment webhooks quickly, turn each delivery into one event, and process it with idempotency, a per-merchant throttle and retries that respect the downstream API. Web page: https://damikun.github.io/inngest-dotnet/examples/webhook-pipeline/ Webhook senders want a fast `200` and retry when they do not get one, sometimes delivering the same webhook more than once. The pattern: verify the request, send an event with the delivery id, answer at once, and do the real work in a function. This example does the first three steps by hand, for a sender the SDK does not know. For Stripe, GitHub, Shopify and senders that follow Standard Webhooks, such as Clerk and Resend, `MapInngestWebhook` does them for you, with the signature checks written out and the delivery id as the event id: see [Webhooks](https://damikun.github.io/inngest-dotnet/guides/webhooks.md). ```csharp title="Program.cs" using System.Security.Cryptography; using System.Text; using System.Text.Json; using Inngest; var builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpClient(client => client.BaseAddress = new Uri("https://ledger.internal/")); builder.Services .AddInngest(options => options.AppId = "payments") .AddFunction(); var app = builder.Build(); app.MapInngest("/api/inngest"); app.MapPost("/webhooks/payments", async (HttpRequest request, IInngestClient inngest, IConfiguration configuration, CancellationToken cancellationToken) => { using var reader = new StreamReader(request.Body); var body = await reader.ReadToEndAsync(cancellationToken); if (!IsSigned(body, request.Headers["Webhook-Signature"].ToString(), configuration["PAYMENTS_WEBHOOK_SECRET"]!)) { return Results.Unauthorized(); } var webhook = JsonSerializer.Deserialize(body, JsonSerializerOptions.Web)!; await inngest.SendAsync(new InngestEvent(PaymentWebhook.EventName, webhook) { // The same delivery sent twice becomes one event. SendAsync(webhook) would send no id. Id = $"payments-webhook-{webhook.DeliveryId}", }, cancellationToken); return Results.Ok(); }); app.Run(); static bool IsSigned(string body, string signature, string secret) { var hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(body)); var expected = Encoding.ASCII.GetBytes(Convert.ToHexString(hash).ToLowerInvariant()); return CryptographicOperations.FixedTimeEquals(expected, Encoding.ASCII.GetBytes(signature)); } public sealed record PaymentWebhook(string DeliveryId, string MerchantId, string Type, string PaymentId, long AmountMinor, string Currency) : IInngestEventData { public static string EventName => "payments/webhook.received"; } ``` ```csharp title="ProcessPaymentWebhook.cs" using System.Net; using Inngest; public sealed class ProcessPaymentWebhook(ILedger ledger) : InngestFunction { public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) { var webhook = context.Event.Data; if (webhook.Type is not ("payment.succeeded" or "payment.refunded")) { // Retrying will not make an unknown type known. throw new NonRetriableException($"Unsupported webhook type {webhook.Type}."); } var entryId = await context.Step.RunAsync("record-in-ledger", ct => ledger.RecordAsync(webhook, ct)); return new { webhook.DeliveryId, entryId }; } } public interface ILedger { Task RecordAsync(PaymentWebhook webhook, CancellationToken cancellationToken); } public sealed class LedgerClient(HttpClient http) : ILedger { public async Task RecordAsync(PaymentWebhook webhook, CancellationToken cancellationToken) { using var response = await http.PostAsJsonAsync("entries", webhook, cancellationToken); if (response.StatusCode == HttpStatusCode.TooManyRequests) { // Come back when the ledger says, instead of on Inngest's own schedule. throw new RetryAfterException("The ledger is rate limiting us.", response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(30)); } response.EnsureSuccessStatusCode(); var entry = await response.Content.ReadFromJsonAsync(cancellationToken); return entry!.Id; } private sealed record LedgerEntry(string Id); } ``` ## Attributes The function's options go on `ProcessPaymentWebhook` as attributes: eight retries, an idempotency key on the delivery id and a throttle per merchant. ```csharp [InngestFunction("process-payment-webhook", Retries = 8, Idempotency = "event.data.deliveryId")] [EventTrigger] [Throttle(10, "1s", Key = "event.data.merchantId")] ``` `Program.cs` registers it with `.AddFunction()`, without options. ## Code first Without the attributes, `Program.cs` registers the function with its options in code: eight retries, an idempotency key on the delivery id and a throttle per merchant. ```csharp title="Program.cs (registration)" inngest.AddFunction(new FunctionOptions { Id = "process-payment-webhook", Triggers = [Trigger.Event()], Retries = 8, Idempotency = "event.data.deliveryId", Throttle = new Throttle(10, TimeSpan.FromSeconds(1)) { Key = "event.data.merchantId" }, }); ``` ## How it works - **Answer fast, work later.** The endpoint verifies the signature, sends one event and returns `200` within milliseconds. Slow or failing downstream systems never make the sender retry. - **Two layers of deduplication.** The event id, built from the delivery id, makes Inngest ignore the same delivery sent twice within 24 hours. `Idempotency` on the function guards the run as well, in case another path sends the same delivery. - **A throttle per merchant.** At most ten webhooks per merchant start per second; the rest queue. A merchant with a burst of refunds cannot crowd out the others. - **Retries that listen.** A `429` from the ledger becomes a `RetryAfterException` with the ledger's own delay. Other failures are retried with Inngest's backoff, up to eight times. - **Fail fast on bad input.** An unsupported webhook type throws `NonRetriableException`, so it fails once instead of nine times. The signature check is a sketch: use the verification your payment provider documents, including its timestamp check. Inngest's guide to [receiving webhooks](https://www.inngest.com/docs/durable-execution/guides-and-advanced/events-and-triggers/receive-webhook-events) describes a variant where Inngest itself receives the webhook. --- # Scheduled reminders with sleep and cancel > Sleep until a user's reminder is due, cancel it when the reminder is deleted, and replace the pending run when the reminder is moved. Web page: https://damikun.github.io/inngest-dotnet/examples/scheduled-reminders/ Reminders are runs that spend nearly all their life asleep. One run per reminder sleeps until the due time and then sends the notification. Deleting the reminder cancels the run; moving it starts a new run that replaces the old one. ```csharp title="SendReminder.cs" using Inngest; public sealed record ReminderScheduled(string ReminderId, string UserId, string Text, DateTimeOffset DueAt) : IInngestEventData { public static string EventName => "reminders/reminder.scheduled"; } public sealed record ReminderDeleted(string ReminderId) : IInngestEventData { public static string EventName => "reminders/reminder.deleted"; } public sealed class SendReminder(INotifier notifier) : InngestFunction { public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) { var reminder = context.Event.Data; // DueAt comes from the event, so it is the same on every call. await context.Step.SleepUntilAsync("wait-until-due", reminder.DueAt); await context.Step.RunAsync("notify", ct => notifier.NotifyAsync(reminder.UserId, reminder.Text, ct)); return new { reminder.ReminderId }; } } public interface INotifier { Task NotifyAsync(string userId, string text, CancellationToken cancellationToken); } ``` The app registers it with its options as attributes on the class, or with the options in code: ## Attributes ```csharp [InngestFunction("send-reminder")] [EventTrigger] // A newer schedule for the same reminder cancels the pending run. [Singleton(SingletonMode.Cancel, Key = "event.data.reminderId")] [CancelOn(Match = "data.reminderId")] ``` The attributes go on `SendReminder`, and `AddFunction` reads them once at startup: ```csharp title="Program.cs (registration)" inngest.AddFunction(); ``` ## Code first ```csharp title="Program.cs (registration)" inngest.AddFunction(new FunctionOptions { Id = "send-reminder", Triggers = [Trigger.Event()], // A newer schedule for the same reminder cancels the pending run. Singleton = new Singleton(SingletonMode.Cancel) { Key = "event.data.reminderId" }, CancelOn = [CancelOn.For() with { Match = "data.reminderId" }], }); ``` The app sends an event whenever a reminder is created or moved, and another when it is deleted: ```csharp title="ReminderService.cs" using Inngest; public sealed class ReminderService(IInngestClient inngest) { public Task ScheduleAsync(string reminderId, string userId, string text, DateTimeOffset dueAt, CancellationToken cancellationToken) => inngest.SendAsync(new ReminderScheduled(reminderId, userId, text, dueAt), cancellationToken); public Task DeleteAsync(string reminderId, CancellationToken cancellationToken) => inngest.SendAsync(new ReminderDeleted(reminderId), cancellationToken); } ``` ## How it works - **Sleeping costs nothing.** `SleepUntilAsync` ends the call. Inngest calls the function again at the due time, which can be weeks away and on another instance of the app. - **Moving a reminder.** Sending `reminders/reminder.scheduled` again for the same reminder starts a new run. The singleton option in cancel mode, keyed on the reminder id, cancels the run that was waiting for the old time. - **Deleting a reminder.** The cancellation on `ReminderDeleted`, with `Match = "data.reminderId"`, cancels the sleeping run when the deletion event names the same reminder. - **Stable times.** The due time comes from the event, which is the same on every call. A time computed from the clock outside a step would move each time the function replays. The [sleep and wait guide](https://damikun.github.io/inngest-dotnet/guides/sleep-and-wait-for-events.md) covers sleeps in detail, and [Flow control](https://damikun.github.io/inngest-dotnet/guides/flow-control.md#singleton) the singleton option. --- # Background worker with Inngest Connect > A background worker with no HTTP endpoint that runs Inngest functions over Connect, with a web app that only sends events, a Dockerfile and graceful shutdown. Web page: https://damikun.github.io/inngest-dotnet/examples/connect-worker/ This example splits an app in two: a public web app that accepts uploads and sends events, and a worker in a private network that processes them. The worker has no inbound port at all. It connects out to Inngest with [Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md) and receives its work over that connection. ## The events The web app sends `ImageUploaded`, which triggers the worker, and the worker announces `ThumbnailsReady` when it is done. The records live in a small class library that both projects reference, so the name and the data of an event are written once. The library needs only the `Inngest` package: ```csharp title="Contracts/Events.cs" using Inngest; public sealed record ImageUploaded(string ImageId, string BlobPath) : IInngestEventData { public static string EventName => "media/image.uploaded"; } public sealed record ThumbnailsReady(string ImageId, string[] Paths) : IInngestEventData { public static string EventName => "media/thumbnails.ready"; } ``` ## The worker ```csharp title="Worker/Program.cs" using Inngest; var builder = Host.CreateApplicationBuilder(args); // Register your IThumbnailer implementation here; any image library works. builder.Services.Configure(options => options.ShutdownTimeout = TimeSpan.FromMinutes(2)); builder.Services .AddInngest(options => options.AppId = "media-worker") .AddFunction() .AddConnect(connect => { // Stable per container, unique between containers. connect.InstanceId = Environment.GetEnvironmentVariable("HOSTNAME"); connect.MaxWorkerConcurrency = 4; }); await builder.Build().RunAsync(); ``` ```csharp title="Worker/MakeThumbnails.cs" using Inngest; public sealed class MakeThumbnails(IThumbnailer thumbnailer) : InngestFunction { private static readonly int[] Widths = [160, 480, 1200]; public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) { var image = context.Event.Data; var paths = await Task.WhenAll(Widths.Select(width => context.Step.RunAsync($"resize-{width}", ct => thumbnailer.ResizeAsync(image.BlobPath, width, ct)))); await context.Step.SendEventAsync("announce", new ThumbnailsReady(image.ImageId, paths)); return new { image.ImageId, thumbnails = paths.Length }; } } public interface IThumbnailer { Task ResizeAsync(string blobPath, int width, CancellationToken cancellationToken); } ``` ### Attributes The function's options go on `MakeThumbnails` as attributes: its id, its trigger and a concurrency limit of 8. ```csharp [InngestFunction("make-thumbnails")] [EventTrigger] [Concurrency(8)] ``` `Worker/Program.cs` registers it with `.AddFunction()`, without options. ### Code first Without the attributes, `Worker/Program.cs` registers the function with its options in code: its id, its trigger and a concurrency limit of 8. ```csharp title="Worker/Program.cs (registration)" inngest.AddFunction(new FunctionOptions { Id = "make-thumbnails", Triggers = [Trigger.Event()], Concurrency = [new ConcurrencyLimit(8)], }); ``` The worker's project uses the worker SDK, references the contracts library and adds the `Inngest.Connect` package, which brings in `Inngest`: ```xml title="Worker/Worker.csproj" net10.0 enable enable ``` ## The web app The web app only sends events, so it needs `AddInngest`, an event key and the contracts library, but no functions and no `MapInngest`: ```csharp title="Web/Program.cs" using Inngest; var builder = WebApplication.CreateBuilder(args); builder.Services.AddInngest(options => options.AppId = "media-web"); var app = builder.Build(); app.MapPost("/images/{imageId}", async (string imageId, IInngestClient inngest, CancellationToken cancellationToken) => { // Store the upload first; then hand the processing to the worker. var blobPath = $"uploads/{imageId}"; await inngest.SendAsync(new ImageUploaded(imageId, blobPath), cancellationToken); return Results.Accepted(); }); app.Run(); ``` ## Running it in containers ```dockerfile title="Worker/Dockerfile" FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY . . RUN dotnet publish Worker/Worker.csproj -c Release -o /app FROM mcr.microsoft.com/dotnet/runtime:10.0 WORKDIR /app COPY --from=build /app . USER $APP_UID ENTRYPOINT ["dotnet", "Worker.dll"] ``` The worker image needs only the .NET runtime, not ASP.NET Core, and exposes no port. Give it the signing key to connect, and the event key because it sends `ThumbnailsReady`; the web app needs the event key: ```bash docker run -e INNGEST_SIGNING_KEY=signkey-prod-... -e INNGEST_EVENT_KEY=... media-worker:latest docker run -p 8080:8080 -e INNGEST_EVENT_KEY=... media-web:latest ``` ## How it works - **No ingress.** The worker dials Inngest; nothing dials the worker. It works from a private subnet, a laptop or a network that only allows outbound traffic. - **Two apps in Inngest.** The worker syncs itself as `media-worker` through its connection. The web app has no functions to sync; it only sends events. - **Capacity per worker.** `MaxWorkerConcurrency` caps how many calls Inngest sends one worker at a time, and Inngest spreads the rest over other workers. The function's own concurrency limit caps all workers together. - **Graceful shutdown.** When the container stops, the worker takes no new work and waits up to two minutes for running calls. Calls cut off after that are cancelled and retried elsewhere. Set the orchestrator's grace period a little longer than the shutdown timeout. - **Several workers.** Run as many containers as you need. Each needs its own `InstanceId`, which the container's host name provides. --- # Tracing runs with OpenTelemetry > Wrap every function call and step in an OpenTelemetry activity with InngestMiddleware, with tags for the function, the run and the step. Web page: https://damikun.github.io/inngest-dotnet/examples/tracing-middleware/ :::note The [`Inngest.OpenTelemetry`](https://www.nuget.org/packages/Inngest.OpenTelemetry/) package does this for you: call `AddInstrumentation()` on the Inngest builder, and `AddInngestInstrumentation()` on your tracer and meter providers. This example shows how to write such a middleware yourself, for tags or spans of your own. ::: Each call from Inngest runs only part of a function, so request traces alone show a series of short, unrelated requests. A middleware can add what the traces lack: an activity per call and per executed step, tagged with the function, the run and the step, so that one run can be followed across calls. ```csharp title="TracingMiddleware.cs" using System.Diagnostics; using Inngest; public sealed class TracingMiddleware : InngestMiddleware { public static readonly ActivitySource Source = new("Shop.Inngest"); public override async Task WrapStepHandlerAsync(FunctionContext context, StepInfo step, Func> next) { using var activity = Source.StartActivity($"step {step.Id}"); activity?.SetTag("inngest.function", context.FunctionId); activity?.SetTag("inngest.run_id", context.RunId); activity?.SetTag("inngest.step.id", step.Id); activity?.SetTag("inngest.step.type", step.Type.ToString()); activity?.SetTag("inngest.attempt", context.Attempt); try { return await next(); } catch (Exception exception) { activity?.SetStatus(ActivityStatusCode.Error, exception.Message); activity?.SetTag("exception.type", exception.GetType().FullName); throw; } } public override ValueTask OnRunStartAsync(FunctionContext context) { Activity.Current?.AddEvent(new ActivityEvent("inngest.run.started")); return ValueTask.CompletedTask; } public override ValueTask OnRunCompleteAsync(FunctionContext context, object? output) { Activity.Current?.SetTag("inngest.run.completed", true); return ValueTask.CompletedTask; } public override ValueTask OnRunErrorAsync(FunctionContext context, Exception exception, bool isFinalAttempt) { Activity.Current?.SetStatus(ActivityStatusCode.Error, exception.Message); Activity.Current?.SetTag("inngest.run.final_failure", isFinalAttempt); return ValueTask.CompletedTask; } } ``` ```csharp title="Program.cs" using Inngest; using OpenTelemetry.Resources; using OpenTelemetry.Trace; var builder = WebApplication.CreateBuilder(args); builder.Services.AddOpenTelemetry() .ConfigureResource(resource => resource.AddService("shop")) .WithTracing(tracing => tracing .AddSource(TracingMiddleware.Source.Name) .AddAspNetCoreInstrumentation() .AddOtlpExporter()); builder.Services .AddInngest(options => options.AppId = "shop") .AddFunction() .AddMiddleware(); var app = builder.Build(); app.MapInngest("/api/inngest"); app.Run(); ``` The `OpenTelemetry.Extensions.Hosting`, `OpenTelemetry.Instrumentation.AspNetCore` and `OpenTelemetry.Exporter.OpenTelemetryProtocol` packages provide the registration. `SyncCatalog` is a function for the middleware to trace. Its attributes give it its id and its trigger, which names the event with a string because the function reads its event as `JsonElement`. With its options passed to `AddFunction` in code as `FunctionOptions` instead, the class needs no attributes. ```csharp title="SyncCatalog.cs" using System.Text.Json; using Inngest; [InngestFunction("sync-catalog")] [EventTrigger("catalog/changed")] public sealed class SyncCatalog : InngestFunction { public override async Task ExecuteAsync(FunctionContext context, CancellationToken cancellationToken) { var count = await context.Step.RunAsync("load-products", () => 42); await context.Step.RunAsync("publish", () => context.Logger.LogInformation("Published {Count} products", count)); return new { count }; } } ``` ## How it works - **Spans around executed steps.** `WrapStepHandlerAsync` runs only when a step's handler actually runs, once per attempt, so memoized steps add no spans and every retry gets one. Its `next()` always completes, so the `using` always ends the activity. - **The request is the parent.** ASP.NET Core instrumentation starts an activity for each call to `/api/inngest`, and the step activities nest under it. When a request from Inngest carries a `traceparent` header, the SDK passes it back on the response. - **Run-level marks.** The observing hooks add an event when a run starts, a tag when it completes and an error status when an attempt fails, on the current request's activity. - **Following a run.** Every span carries `inngest.run_id`, so a query on that tag shows all calls and steps of one run, across instances of the app. `WrapFunctionAsync` would be the natural place for a span around the whole call, but its `next()` does not complete when a call ends at a new step; see [Middleware](https://damikun.github.io/inngest-dotnet/guides/middleware.md#when-next-never-returns). --- # C# API overview > The public types of the Inngest, Inngest.AspNetCore, Inngest.AwsLambda, Inngest.AzureFunctions, Inngest.Blazor, Inngest.Connect, Inngest.Aspire.Hosting, Inngest.OpenTelemetry, Inngest.Encryption, Inngest.Sentry, Inngest.SignalR, Inngest.EntityFrameworkCore, Inngest.Extensions.AI, Inngest.Testing, Inngest.Testing.Containers, Inngest.Testing.Containers.AspNetCore, Inngest.UserContext, Inngest.UserContext.AspNetCore and Inngest.Webhooks packages, the method and diagnostics of the source generator in Inngest, the templates of Inngest.Templates and the commands of dotnet-inngest, with what they are for. Web page: https://damikun.github.io/inngest-dotnet/reference/api/ Every public type of the SDK, grouped by what it is for. The guides explain how to use them; this page is for looking things up. Types are in the `Inngest` namespace unless noted. ## Registration | Member | Purpose | | - | - | | `IServiceCollection.AddInngest(Action?)` | Registers the SDK and returns an `IInngestBuilder`. In `Microsoft.Extensions.DependencyInjection`. | | `IServiceCollection.AddInngest(IConfiguration, Action?)` | The same, with settings read from a configuration section such as `Inngest` in `appsettings.json`. | | `IInngestBuilder.AddFunction(FunctionOptions)` | Registers a class function with these options, resolved from a scope for every call. Attributes on the class are ignored. | | `IInngestBuilder.AddFunction()` | Registers a class function configured by its [attributes](https://damikun.github.io/inngest-dotnet/reference/api.md#attributes). | | `IInngestBuilder.AddFunction(Func)` | Registers a class function with the options its attributes give, as the callback changes them. | | `IInngestBuilder.AddInngestFunctions()` | Written into your project by the source generator, once the project sets `InngestGenerateFunctionRegistration` to `true`: registers every class that has `[InngestFunction]`, as `AddFunction()` does. See [Source generator and analyzers](https://damikun.github.io/inngest-dotnet/reference/api.md#source-generator-and-analyzers). | | `IInngestBuilder.AddFunction(FunctionOptions, handler, onFailure?)` | Registers a delegate function with typed event data. | | `IInngestBuilder.AddFunction(FunctionOptions, handler, onFailure?)` | Registers a delegate function with `JsonElement` event data. | | `IInngestBuilder.AddMiddleware()` | Registers middleware, one instance per call. | | `IInngestBuilder.AddMiddleware(InngestMiddleware)` | Registers one middleware instance for all calls. | | `IInngestBuilder.AddConnect(Action?)` | Runs the functions in a Connect worker. Package `Inngest.Connect`. | | `IInngestBuilder.AddEntityFrameworkOutbox(Action?)` | Adds the transactional outbox and its hosted dispatcher for a `DbContext`. Package `Inngest.EntityFrameworkCore`. See [Entity Framework Core outbox](https://damikun.github.io/inngest-dotnet/reference/api.md#entity-framework-core-outbox). | | `IEndpointRouteBuilder.MapInngest(string pattern = "/api/inngest")` | Maps the endpoint Inngest calls. Package `Inngest.AspNetCore`, namespace `Microsoft.AspNetCore.Builder`. | | `IEndpointRouteBuilder.MapInngestRealtime(string pattern, Func> subscription)` | Serves realtime messages to browsers as server-sent events; the callback returns the `RealtimeSubscription(string Channel, IReadOnlyList Topics)` a request may receive, or null for 403. Package `Inngest.AspNetCore`, namespace `Microsoft.AspNetCore.Builder`. See [Realtime](https://damikun.github.io/inngest-dotnet/guides/realtime.md#with-server-sent-events). | | `IHealthChecksBuilder.AddInngest(string? name = null, HealthStatus? failureStatus = null, IEnumerable? tags = null, TimeSpan? timeout = null)` | Adds a health check that tells a readiness probe whether an app that serves functions can work with Inngest: its keys are configured and Inngest answers a request that carries the signing key. Name `"inngest"` unless given. Package `Inngest.AspNetCore`, in `Microsoft.Extensions.DependencyInjection`. See [Health checks](https://damikun.github.io/inngest-dotnet/guides/deploying.md#health-checks). | | `IHealthChecksBuilder.AddInngest(Action, string? name = null, HealthStatus? failureStatus = null, IEnumerable? tags = null, TimeSpan? timeout = null)` | The same, with the options set by the callback. | | `IApplicationBuilder.UseInngestDurableEndpoints()` | Runs the endpoints marked as durable as Inngest runs. Add it after routing. Package `Inngest.AspNetCore`, namespace `Microsoft.AspNetCore.Builder`. See [Durable endpoints](https://damikun.github.io/inngest-dotnet/guides/durable-endpoints.md). | | `IEndpointConventionBuilder.WithDurableEndpoint(DurableEndpointOptions? options = null)` | Marks an endpoint as durable, so that its handler can take `IStepTools` and run steps. Also `WithDurableEndpoint(string id)`. Package `Inngest.AspNetCore`, namespace `Microsoft.AspNetCore.Builder`. | | `IServiceCollection.AddInngestRealtimeServerSource()` | Makes Blazor components read realtime messages from Inngest through `IInngestRealtime`, on the server. Package `Inngest.Blazor`. In `Microsoft.Extensions.DependencyInjection`. See [Realtime in Blazor](https://damikun.github.io/inngest-dotnet/guides/blazor.md). | | `IServiceCollection.AddInngestRealtimeRelaySource(Action)` | Makes Blazor components read the app's `MapInngestRealtime` endpoint over an `HttpClient`, in WebAssembly. Returns the `IHttpClientBuilder` of that client. Package `Inngest.Blazor`. The source registered last, of the two, is the one components get. | | `IEndpointRouteBuilder.MapInngestWebhook(string pattern, WebhookProvider provider, WebhookOptions? options = null)` | Maps a POST endpoint that verifies a provider's webhooks and sends each as an event with the delivery id as its id. Package `Inngest.AspNetCore`, which brings in `Inngest.Webhooks`, namespace `Microsoft.AspNetCore.Builder`. See [Webhooks](https://damikun.github.io/inngest-dotnet/guides/webhooks.md). | | `IHttpClientBuilder.AddInngestStepFetch()` | Turns the requests an `HttpClient` sends from a running function into fetch steps, which Inngest makes. Requests from step handlers and from outside functions go out as usual. Add it last. In `Microsoft.Extensions.DependencyInjection`. | | `IInngestBuilder.Services` | The service collection the SDK is registered in. | `AddInngest` also registers `IInngestClient`, `IInngestRunsClient` and `IInngestRealtime`, and `IStepTools`, which resolves to the steps of the durable endpoint or function that is running. ## Options **`InngestOptions`**: `AppId`, `AppVersion`, `EventKey`, `SigningKey`, `SigningKeyFallback`, `Environment`, `IsDev`, `DevServerUrl`, `BaseUrl`, `ApiBaseUrl`, `EventApiBaseUrl`, `ServeOrigin`, `ServePath`, `AllowInBandSync`, `AllowUnsignedSync`, `Streaming`, `SessionPropagation`, `Checkpointing`, `SerializerOptions`. See [Configuration](https://damikun.github.io/inngest-dotnet/guides/configuration.md). **`ConnectOptions`**: `InstanceId`, `MaxWorkerConcurrency`, `GatewayUrl`. See [Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md). **`InngestHealthCheckOptions`**: `RequireEventKey`, `CheckSync`, `CacheDuration` (30 seconds) and `FailureCacheDuration` (5 seconds). See [Health checks](https://damikun.github.io/inngest-dotnet/guides/deploying.md#health-checks). **`FunctionOptions`**, an immutable record: | Property | Type | | - | - | | `Id` (required) | `string` | | `Name` | `string?` | | `Triggers` (required) | `IReadOnlyList` | | `Retries` | `int?` | | `Concurrency` | `IReadOnlyList?` | | `Throttle`, `RateLimit`, `Debounce`, `BatchEvents`, `Priority` | records of the same names | | `Idempotency` | `string?` | | `CancelOn` | `IReadOnlyList?` | | `Timeouts` | `FunctionTimeouts?` | | `Singleton` | `Singleton?` | | `Middleware` | `IReadOnlyList?`; see [Middleware](https://damikun.github.io/inngest-dotnet/reference/api.md#middleware) | | `Checkpointing` | `CheckpointingOptions?` | The flow control records and their constructor arguments and properties: | Record | Constructor | Properties | | - | - | - | | `ConcurrencyLimit` | `(int Limit)`; 0 means no limit | `Key`, `Scope` (`ConcurrencyScope.Function`, `Environment`, `Account`) | | `Throttle` | `(int Limit, TimeSpan Period)` | `Burst`, `Key` | | `RateLimit` | `(int Limit, TimeSpan Period)` | `Key` | | `Debounce` | `(TimeSpan Period)` | `Key`, `Timeout` | | `BatchEvents` | `(int MaxSize, TimeSpan Timeout)` | `Key`, `If` | | `Priority` | `(string Run)` | | | `CancelOn` | `(string Event)` | `If`, `Match`, `Timeout` | | `FunctionTimeouts` | | `Start`, `Finish` | | `Singleton` | `(SingletonMode Mode)` | `Key`; modes `Skip` and `Cancel` | **Triggers**: `Trigger.Event(string name, string? @if = null)` and `Trigger.Event(string? @if = null)`, for a typed event `T`, return an `EventTrigger`. `Trigger.Cron(string schedule, string? timezone = null)` and `Trigger.Cron(string schedule, TimeSpan jitter, string? timezone = null)` return a `CronTrigger`, whose `Jitter`, from 1 second to 5 minutes, starts each scheduled run up to that much later. `EventTrigger` and `CronTrigger` derive from `FunctionTrigger`. `CancelOn.For()` returns a `CancelOn` for a typed event. **`CheckpointingOptions`**, a record. Checkpointing runs the steps of a function one after another in the same call from Inngest and saves each to Inngest as it completes, instead of ending the call after every step. It is off by default. `InngestOptions.Checkpointing` sets it for every function, and `FunctionOptions.Checkpointing`, which wins over it, for one function. | Property | Default | Meaning | | - | - | - | | `Enabled` | `true` | `false` turns checkpointing off for one function when the app turns it on. | | `MaxRuntime` | null: 10 seconds for the serve endpoint, 5 minutes for Connect workers | How long one call may keep running steps. `Timeout.InfiniteTimeSpan` removes the limit. | | `BufferedSteps` | `1` | How many completed steps are saved to Inngest together. | | `MaxInterval` | null | The longest a completed step waits to be saved while `BufferedSteps` is not reached; null waits until the buffer is full or the call ends. | ## Attributes Each sets the `FunctionOptions` member of the same name; durations are time strings such as `"30s"` or `"1h30m"`. `AddFunction()` reads them when the app starts: it starts from `[InngestFunction]` and applies the others, which derive from `FunctionOptionsAttribute`, in ascending `Order`, then in the order they are declared. | Attribute | Constructor | Properties | | - | - | - | | `InngestFunctionAttribute` | `(string id)` | `Name`, `Retries`, `Idempotency` | | `EventTriggerAttribute`, `EventTriggerAttribute` | `(string name)`, `()` | `If`; repeatable | | `CronTriggerAttribute` | `(string schedule)` | `Timezone`, `Jitter`; repeatable | | `ConcurrencyAttribute` | `(int limit)` | `Key`, `Scope`; repeatable | | `ThrottleAttribute` | `(int limit, string period)` | `Burst`, `Key` | | `RateLimitAttribute` | `(int limit, string period)` | `Key` | | `DebounceAttribute` | `(string period)` | `Key`, `Timeout` | | `BatchEventsAttribute` | `(int maxSize, string timeout)` | `Key`, `If` | | `PriorityAttribute` | `(string run)` | | | `SingletonAttribute` | `(SingletonMode mode)` | `Key` | | `CancelOnAttribute`, `CancelOnAttribute` | `(string name)`, `()` | `If`, `Match`, `Timeout`; repeatable | | `TimeoutsAttribute` | | `Start`, `Finish` | | `FunctionOptionsAttribute` | | `Order`; `Apply(FunctionOptions)` returns the options with the attribute applied. Derive from it and override `Configure(FunctionOptions)` for an attribute of your own. | The generic forms take the event's name from `T`, which implements `IInngestEventData`. ## Functions | Type | Purpose | | - | - | | `InngestFunction` | Base class of class functions. Override `ExecuteAsync(FunctionContext, CancellationToken)`, which returns `Task`. | | `InngestFunction` | Base class of class functions with a result type: `ExecuteAsync` returns `Task`. `InngestFunction` derives from it with `object?`. | | `InngestFunction` | The base of both, which the methods that take a function class require. A class cannot derive from it directly. | | `IReturns` | Implemented by `InngestFunction`, so that `InvokeAsync` can require the result type. | | `FunctionContext` | What every call has: `FunctionId`, `RunId`, `Attempt`, `MaxAttempts`, `Step`, `Logger`, `Services`, `CancellationToken`, `RequestId`, `JobId` and `Sessions`, the run's sessions that what it sends inherits. For code that does not know the event type, such as middleware: `EventName`, `EventId` and `RawEvent`, the triggering event as JSON. | | `FunctionContext` | Adds `Event` and `Events`. | | `IFailureHandler` | Implement `OnFailureAsync(FunctionContext, CancellationToken)` to handle final failures. | | `FunctionFailure` | The failure handler's event data: `FunctionId`, `RunId`, `Error`, `Event`. | | `FunctionError` | A failure as Inngest stores it: `Name`, `Message`, `Stack`, and `Cause`, the error that caused it. | | `SystemEvents` | The names of the events Inngest sends about runs: `FunctionFailed`, `FunctionFinished`, `FunctionCancelled`, `FunctionInvoked`, `ScheduledTimer`. | | `FunctionFinished` | The data of `inngest/function.finished`, sent when a run ends however it ends: `FunctionId`, `RunId`, `Result`, `Error`, `Event`, `CorrelationId`. | | `FunctionCancelled` | The data of `inngest/function.cancelled`: `FunctionId`, `RunId`, `Event`, `CorrelationId`. | ## Steps **`IStepTools`**, the type of `context.Step`: | Method | Returns | | - | - | | `RunAsync(string id, Func> handler)` | `Task` | | `SleepAsync(string id, TimeSpan duration)` | `Task` | | `SleepUntilAsync(string id, DateTimeOffset until)` | `Task` | | `WaitForEventAsync(string id, WaitForEventOptions options)` | `Task?>` | | `InvokeAsync(string id, InvokeOptions options)` | `Task` | | `SendEventAsync(string id, IReadOnlyList events)` | `Task` | | `InvokeAsync(string id, object? data = null, TimeSpan? timeout = null)` | `Task`; runs the function registered as `TFunction`, which must return a `TResult` | | `WaitForSignalAsync(string id, WaitForSignalOptions options)` | `Task?>`; null when the timeout passes first | | `SendSignalAsync(string id, InngestSignal signal)` | `Task`: the id of the run the signal resumed, or null when no run waits for it | | `FetchAsync(string id, HttpRequestMessage request)` | `Task`; Inngest makes the request, and the response is memoized | | `InferAsync(string id, InferOptions options)` | `Task`; Inngest sends the request to the AI provider, and the response is memoized | | `AIWrapAsync(string id, TInput input, Func> handler)` | `Task`; runs your own model call like a run step that the Inngest UI shows as one, with the input; a rerun with an input edited there passes the edited input | | `RunScopedAsync(string id, Func> handler)` | `Task`; runs the handler once like a run step, in a dependency injection scope of its own that is created when the handler starts and disposed when it ends. See [Scoped steps](https://damikun.github.io/inngest-dotnet/guides/scoped-steps.md) | | `PublishAsync(string id, RealtimeTopic topic, T data)` | `Task`; publishes a [realtime](https://damikun.github.io/inngest-dotnet/reference/api.md#realtime) message once, tagged with the run; also for a `RealtimeTopic` | | `PublishStreamAsync(string id, RealtimeTopic topic, IAsyncEnumerable chunks)` | `Task`; streams the text to the topic as it arrives and memoizes all of it, so a replay does not read the chunks again | | `ParallelAsync(ParallelMode mode, Func> steps)` | `Task`; runs the callback with the mode applied to every step it creates | The members after the first six have default implementations, so a fake only has to implement the six. The defaults of `InvokeAsync`, `WaitForSignalAsync`, `SendSignalAsync`, `FetchAsync`, `InferAsync`, `AIWrapAsync`, `RunScopedAsync`, `PublishAsync` and `PublishStreamAsync` throw `NotSupportedException`. Every method but `ParallelAsync` also has an overload that takes a `StepOptions` in place of the id. `StepToolsExtensions` adds `RunAsync` overloads for handlers that take no token, return no value or are synchronous, a non-generic `WaitForEventAsync` that returns `JsonElement` data, and `SendEventAsync` for a single event. For a typed event `T` it adds `WaitForEventAsync(string id, TimeSpan timeout, string? match = null, string? @if = null)` and `SendEventAsync` for one `T` or several. It also adds a non-generic `WaitForSignalAsync` with `JsonElement` data, an `InferAsync` that returns `JsonElement`, an `AIWrapAsync` whose handler takes no token, and `ParallelAsync(ParallelMode, Func)` for steps without a result. For scoped steps it adds a `RunScopedAsync` whose handler returns no result, and `RunScopedAsync` and `RunScopedAsync`, whose handler takes the `TService` that the SDK resolves from the step's scope with `GetRequiredService`. These methods also take a `StepOptions` in place of the id, except `ParallelAsync`. | Record | Constructor | Properties | | - | - | - | | `StepOptions` | `(string Id)` | `Name`, shown in the Inngest UI; `ParallelMode`, for this step instead of the mode of an enclosing `ParallelAsync`. A `string` converts to it implicitly. | | `WaitForEventOptions` | `(string Event, TimeSpan Timeout)` | `Match`, `If` | | `InvokeOptions` | `(string FunctionId)` | `AppId`, `Data`, `User`, `Timeout`, `Meta`, the sessions of the invoked run | | `WaitForSignalOptions` | `(string Signal, TimeSpan Timeout)` | `OnConflict`: `SignalConflict.Fail`, the default, fails the run when another run already waits for the signal; `Replace` takes the signal over | | `InngestSignal` | `(string Name, object? Data = null)` | A signal to send. | | `InngestSignal` | `(string Name, TData Data)` | A signal as the waiting run receives it. | | `InferOptions` | `(AIFormat Format, Uri Url, object Body)` | `AuthKey`, `Headers` | `ParallelMode` is `Wait`, the default, where Inngest calls the function again once all the steps started together have finished, or `Race`, where it calls the function again each time one of them finishes. See [Parallel steps](https://damikun.github.io/inngest-dotnet/guides/parallel-steps.md). `AIFormat` names the provider API: `OpenAIChat`, `OpenAIResponses`, `Anthropic`, `Gemini`, `Grok` or `AzureOpenAI`. ## Fan-out `FanOutExtensions` adds methods to `IStepTools` that run a step for each item of an `IReadOnlyList`, with the id `id[index]`, and return the results, as arrays like `Task.WhenAll`, in the order of the items. They need nothing from the engine's public surface, so they also work on a fake of `IStepTools`. Each takes `FanOutOptions? options = null` and `CancellationToken cancellationToken = default` after the arguments listed here. See [Fan-out over a list](https://damikun.github.io/inngest-dotnet/guides/fan-out.md). | Method | Returns | | - | - | | `MapAsync(string id, IReadOnlyList items, Func> handler)` | `Task`; the first item that fails for good stops the list, and its `StepFailedException` is thrown | | `ForEachAsync(string id, IReadOnlyList items, Func handler)` | `Task`; the same for a handler without a result | | `MapOutcomesAsync(string id, IReadOnlyList items, Func> handler)` | `Task[]>`; every item runs, and each has an outcome | | `MapInChunksAsync(string id, IReadOnlyList items, int chunkSize)` | `Task`; each chunk of `chunkSize` items runs in a run of the `FanOutFunction` class `TFunction`, with the id `id[first-last]` | | `MapOutcomesInChunksAsync(string id, IReadOnlyList items, int chunkSize)` | `Task[]>` | Inngest fails a run once 1,000 of its steps have completed, and every step of the run counts. So the methods without chunks refuse a list of 1,000 items or more, and the chunked methods a `chunkSize` of 1,000 or more and a list that makes 1,000 chunks or more, with a `NonRetriableException`, which is not retried. Fewer fit when the function has other steps. | Type | Members | | - | - | | `FanOutOptions`, a record | `MaxParallelism`: how many items, or chunks, run at once, all of them by default. `ParallelMode`: `Wait`, `Race`, or null for the mode of the enclosing `ParallelAsync`. `ChunkTimeout`: how long a chunk's run may take. | | `FanOutOutcome`, a record struct | `Index`, `Value`, `Error` (a `FunctionError?`) and `Succeeded` | | `FanOutFunction`, an abstract class | The function of a chunk. Implement `ProcessAsync(TItem item, CancellationToken cancellationToken)`; override `MaxParallelism`, the number of items of a chunk that run at once. Register it with `AddFunction()`, with no trigger. | | `FanOutChunk`, a record | `(int Offset, IReadOnlyList Items, bool FailFast)`: the event data of a chunk's run. A source-generated `JsonSerializerContext` needs it, and `FanOutOutcome[]`, the output of the run. | ## Events | Type | Purpose | | - | - | | `IInngestClient` | `SendAsync` for one `InngestEvent` or several, each also with a `SendOptions`; `SendSignalAsync(InngestSignal, CancellationToken)`, also with a `SendOptions`, which returns the id of the run the signal resumed, or null when no run waits for it; `AppId`. | | `IInngestEventData` | Implemented by an event's data record: `static abstract string EventName` and `static virtual string? EventVersion`. | | `InngestClientExtensions` | `SendAsync(T, CancellationToken)` and `SendAsync(IEnumerable, CancellationToken)` for typed events. | | `InngestEvent` | An event to send: `(string Name, object? Data = null)` with `Id`, `User`, `Timestamp`, `Version`, `Meta`. | | `InngestEvent` | A received event: `Name`, `Data`, `Id`, `User`, `Timestamp`, `Version`, `Meta`. | | `EventMeta` | An event's sessions: `Sessions`, its own, where a null id drops an inherited session; `PropagatedSessions`, those it inherits from the run that sends it, which the SDK fills in; `InheritSessions`, false to inherit none. See [Sending events](https://damikun.github.io/inngest-dotnet/guides/sending-events.md#sessions). | | `SendEventsResult` | `Ids` of the sent events, in order. | | `SendOptions` | Options for one send: `Environment`, a branch environment to send to instead of the app's. | ## Runs **`IInngestRunsClient`** reads and cancels runs through the Inngest REST API, with the app's signing key: | Method | Returns | | - | - | | `GetRunAsync(string runId, CancellationToken)` | `Task`: the run's status and, once it has ended, its output | | `GetEventRunsAsync(string eventId, CancellationToken)` | `Task>`: the runs an event started, by the event id that sending it returned | | `CancelRunAsync(string runId, CancellationToken)` | `Task`; a step that is running finishes, and no further steps run | **`FunctionRun`**, a record: `RunId`, `Status`, `FunctionId` (Inngest's own id of the function, a UUID), `FunctionVersion`, `StartedAt`, `EndedAt`, `EventId`, `BatchId`, `OriginalRunId`, `Cron`, `EnvironmentId`, `Output` and `GetOutput()`, which reads the output with the app's serializer options. `Status` is a `FunctionRunStatus`: `Unknown`, `Scheduled`, `Running`, `Completed`, `Failed` or `Cancelled`. Inngest caches these answers for a few seconds, a run for 3 and the runs of an event for 15, and only `EndedAt` says for sure that a run is over. Failures throw `InngestException` with Inngest's message. ## Realtime **`IInngestRealtime`** publishes, streams and receives realtime messages and mints subscription tokens through the Inngest API, with the app's signing key. See [Realtime](https://damikun.github.io/inngest-dotnet/guides/realtime.md). | Method | Returns | | - | - | | `PublishAsync(RealtimeTopic topic, T data, CancellationToken)` | `Task`; publishes at once, tagged with the run when called inside one. Text goes out as it is, anything else as JSON. Also for a `RealtimeTopic`. | | `PublishStreamAsync(RealtimeTopic topic, IAsyncEnumerable chunks, CancellationToken)` | `Task`: all the text, streamed to the topic in one request as it arrives | | `SubscribeAsync(RealtimeTopic topic, CancellationToken)` | `IAsyncEnumerable>`: the topic's messages from now on, with the data read as a `T` | | `SubscribeAsync(string channel, IReadOnlyList topics, CancellationToken)` | `IAsyncEnumerable>` for several topics of a channel | | `GetSubscriptionTokenAsync(string channel, IReadOnlyList topics, CancellationToken)` | `Task` for a browser to subscribe to these topics of the channel | **`RealtimeTopic`**, a record `(string Channel, string Name)`, and **`RealtimeTopic`**, the same for data of type `T`, with `Untyped` for the members that take any topic. **`RealtimeMessage`**, a record `(RealtimeMessageKind Kind, string Channel, string Topic)` with `Data`, `StreamId`, `Text`, `RunId` and `CreatedAt`; `RealtimeMessageKind` is `Data`, `StreamStart`, `StreamChunk`, `StreamEnd`, `Run` or `Other`. **`RealtimeToken`**, a record `(string Channel, IReadOnlyList Topics, string Key)` with `ApiBaseUrl`; serialized with camelCase names, it is the token object that `useRealtime` and `subscribe` of the TypeScript SDK take. ## Durable endpoints Package `Inngest.AspNetCore`. The API is marked `[Experimental("INNGEST001")]`, because Inngest calls durable endpoints beta; [Durable endpoints](https://damikun.github.io/inngest-dotnet/guides/durable-endpoints.md#beta) shows how to suppress the diagnostic. **`DurableEndpointOptions`**, the argument of `WithDurableEndpoint`: | Property | Type | Default | | - | - | - | | `Id` | `string?` | The method and the route pattern, such as `POST /orders/{id}` | | `Retries` | `int?` | 3 | | `AsyncResponse` | `DurableEndpointResponse` | `Redirect` | | `RedirectUrl` | `Func?` | The output URL on the Inngest API | | `MaxRequestBodySize` | `int` | 1 MiB; a larger request gets 413 | **`DurableEndpointResponse`** is what the client gets when Inngest takes the run over: `Redirect`, a 303 to the URL that answers with the endpoint's response, or `Token`, a 200 with `{"run_id", "token"}`. **`DurableEndpointRun`**, a record `(string RunId, string Token)`, is what `RedirectUrl` receives. **`DurableEndpointAttribute`** marks a controller or an action, as `WithDurableEndpoint` does for a minimal API endpoint. Its constructor is `(string? id = null)`, and its properties are `Retries`, where a negative number means the default, and `AsyncResponse`. **`DurableEndpointDiagnostics.Id`** is `"INNGEST001"`, the diagnostic that marks the API. ## Errors | Exception | Meaning | | - | - | | `NonRetriableException` | Thrown by your code to fail a step or run without retrying. | | `RetryAfterException` | Thrown by your code to retry after `Delay` or at `RetryAt`. | | `StepFailedException` | Thrown by the SDK where a step that failed for good is awaited: `StepId`, `Error`. When the recorded error has a cause, `InnerException` is a `FunctionErrorException` for it. | | `FunctionErrorException` | An error Inngest recorded, rebuilt as an exception: `Error` holds the recorded name, message and stack, and `InnerException` the recorded cause. | | `InngestException` | Thrown by the SDK when talking to Inngest fails, with `StatusCode` when there is one. | ## Middleware `InngestMiddleware` has virtual hooks that do nothing by default: `OnRunStartAsync`, `OnMemoizationEndAsync`, `OnRunCompleteAsync`, `OnRunErrorAsync`, `OnStepStartAsync`, `OnStepCompleteAsync`, `OnStepErrorAsync`, `WrapFunctionAsync`, `WrapStepAsync`, `WrapStepHandlerAsync` and `WrapSendEventAsync`. `StepInfo` describes a step: `Id`, `HashedId`, `Type`, `Memoized`, and `Name`, the display name the code gave it. `Type` is a `StepType`: `Run`, `Sleep`, `WaitForEvent`, `Invoke`, `SendEvent`, `WaitForSignal`, `SendSignal`, `Fetch`, `AIInfer`, `AIWrap` or `RealtimePublish`. See [Middleware](https://damikun.github.io/inngest-dotnet/guides/middleware.md). **`FunctionMiddleware`** is middleware for one function, listed in `FunctionOptions.Middleware`. It runs inside the app's middleware, also for the function's failure handler. `FunctionMiddleware.Of()` resolves `TMiddleware` from the call's scope and registers it as scoped unless the app registered it; `FunctionMiddleware.Of(Func)` calls the factory with the call's scope on every call. **`IPayloadTransform`**, implemented by a middleware, rewrites the raw JSON between Inngest and the function: | Method | Rewrites | | - | - | | `TransformEvent(JsonElement @event, Utf8JsonWriter writer)` | A triggering event, before the function's event type is read from it | | `TransformStepResult(FunctionContext context, StepInfo step, JsonElement result, Utf8JsonWriter writer)` | A memoized result of any step type, before the step's result type is read from it | | `TransformStepOutput(FunctionContext context, StepInfo step, JsonElement output, Utf8JsonWriter writer)` | What a step handler returned, before Inngest memoizes it | | `TransformFunctionOutput(FunctionContext context, JsonElement output, Utf8JsonWriter writer)` | The function's output, before it is sent to Inngest | Each method writes one replacement value and returns `true`, or writes nothing and returns `false` to keep the value; the defaults keep every value. JSON coming in reaches the transforms in registration order, and JSON going out in reverse order. ## Connect Package `Inngest.Connect`. `AddConnect` registers **`IInngestConnection`**, the worker's connection to Inngest: `State`, and `WaitForStateChangedAsync(InngestConnectionState lastObservedState, CancellationToken)`, which completes with the state the connection moves to. `InngestConnectionState` is `Connecting`, `Active`, `Reconnecting`, `Closing` or `Closed`. `IHealthChecksBuilder.AddInngestConnect(string name = "inngest-connect", HealthStatus? failureStatus = null, IEnumerable? tags = null)`, in `Microsoft.Extensions.DependencyInjection`, adds a health check that is healthy only while the connection is `Active`. See [Deploying](https://damikun.github.io/inngest-dotnet/guides/deploying.md). ## Aspire Package `Inngest.Aspire.Hosting`, for the AppHost project. The resource type is in `Aspire.Hosting.ApplicationModel` and the extension methods, on `IDistributedApplicationBuilder` and `IResourceBuilder`, are in `Aspire.Hosting`, the namespaces an AppHost imports. See [Aspire](https://damikun.github.io/inngest-dotnet/guides/aspire.md). | Member | Purpose | | - | - | | `AddInngest(string name, int? port = null, int? connectPort = null)` | Adds the dev server as a container running `inngest dev --no-discovery`, with health checks on `/health` and on the gateway's `/ready` and its UI linked in the dashboard. `port` and `connectPort` fix the host ports of the API and the gateway. The dev server is left out of a manifest. Returns an `IResourceBuilder`. | | `AddInngestSelfHosted(string name, IResourceBuilder? signingKey = null, IResourceBuilder? eventKey = null, int? port = null, int? connectPort = null)` | Adds a self-hosted server, `inngest start`, that signs its requests and wants an event key. Keys that are not given are generated and kept in the user secrets of the AppHost. A key that `inngest start` would refuse is reported when the server starts. The server is part of a manifest, with the keys as parameters. | | `IResourceBuilder.WithApp(IResourceBuilder app, string path = "/api/inngest", string endpointName = "http")` | Makes the server sync an app that serves functions, at the address its container reaches the endpoint on. Call it once for every app. | | `IResourceBuilder.WithReference(IResourceBuilder inngest)` | Gives a project or container `INNGEST_DEV` (the dev server) or `INNGEST_DEV=0`, `INNGEST_SIGNING_KEY` and `INNGEST_EVENT_KEY` (the self-hosted server), and for both `INNGEST_BASE_URL`, `INNGEST_API_BASE_URL` and `INNGEST_EVENT_API_BASE_URL` with the server's address, and `INNGEST_CONNECT_GATEWAY_URL` for workers. In a manifest a reference to the dev server adds nothing. | **`InngestServerResource`**, a `ContainerResource`: `IsSelfHosted`; `SigningKeyParameter` and `EventKeyParameter`, which are null for the dev server; `PrimaryEndpoint`, the endpoint named `http` that serves the REST API, the event API and the UI; and `ConnectEndpoint`, the endpoint named `connect` that is the gateway of Connect workers. Its constructor takes the name and, for a self-hosted server, both keys. ## AWS Lambda Package `Inngest.AwsLambda`. See [Running on AWS Lambda](https://damikun.github.io/inngest-dotnet/guides/aws-lambda.md). | Member | Purpose | | - | - | | `InngestLambdaHandler(IServiceProvider services)` | Takes the SDK from the service provider of an app that called `AddInngest`, and fails at once when it did not. Build one when the function starts. | | `HandleAsync(APIGatewayProxyRequest request, ILambdaContext context)` | Serves an event of an API Gateway REST API (payload format 1.0) and returns an `APIGatewayProxyResponse`. | | `HandleAsync(APIGatewayHttpApiV2ProxyRequest request, ILambdaContext context)` | Serves an event of an API Gateway HTTP API or a Lambda function URL (payload format 2.0) and returns an `APIGatewayHttpApiV2ProxyResponse`. | Each invocation runs in a service scope of its own. The cancellation token that functions receive is never cancelled, and `InngestOptions.Streaming` has no effect, since the response is buffered; building the handler logs a warning when it is on. The event types are those of `Amazon.Lambda.APIGatewayEvents`, and the package ships no `JsonSerializerContext` for them. ## Azure Functions Package `Inngest.AzureFunctions`. | Member | Purpose | | - | - | | `HttpRequestData.ServeInngestAsync()` | Serves the Inngest endpoint from an HTTP-triggered function of the isolated worker: `GET` answers introspection, `PUT` syncs the app and `POST` runs functions. Returns the `HttpResponseData` the function returns. It works with the built-in HTTP model and with the ASP.NET Core integration, where `InngestOptions.Streaming` streams a long call. Requires `services.AddInngest()`. In `Microsoft.Azure.Functions.Worker.Http`. | The function that calls it is declared in the app. See [Azure Functions](https://damikun.github.io/inngest-dotnet/guides/azure-functions.md). ## OpenTelemetry Package `Inngest.OpenTelemetry`. | Member | Purpose | | - | - | | `IInngestBuilder.AddInstrumentation()` | Adds the middleware that traces and measures runs. Add it before other middleware, so that their work falls inside its spans. | | `TracerProviderBuilder.AddInngestInstrumentation()` | Collects the SDK's spans. In `OpenTelemetry.Trace`. | | `MeterProviderBuilder.AddInngestInstrumentation()` | Collects the SDK's metrics. In `OpenTelemetry.Metrics`. | | `InngestInstrumentation.ActivitySourceName`, `InngestInstrumentation.MeterName` | `"inngest"`, for `AddSource`, `AddMeter` or a listener of your own. | The spans are `inngest.execution`, one per call from Inngest, and `inngest.step`, one per step the app executes; a Connect worker adds `inngest.connect.request` for every request it receives. The metrics are `inngest.run.started`, `inngest.run.completed`, `inngest.run.failed`, `inngest.retries`, `inngest.execution.duration` and `inngest.step.duration`, with durations in seconds. The [tracing middleware example](https://damikun.github.io/inngest-dotnet/examples/tracing-middleware.md) shows how to write such a middleware yourself. ## Encryption Package `Inngest.Encryption`. See [Encryption](https://damikun.github.io/inngest-dotnet/guides/middleware.md#encryption). | Member | Purpose | | - | - | | `IInngestBuilder.AddEncryption(Action?)` | Adds the middleware that encrypts event data, step results and function output and decrypts what comes back, in the format of `@inngest/middleware-encryption`. Register it after middleware that add or change events. | **`EncryptionOptions`**: | Member | Purpose | | - | - | | `Key` | The key that encrypts, and the first one tried when decrypting. Required unless `Service` is set. | | `FallbackDecryptionKeys` | Keys that only decrypt, for replacing a key. Empty by default. | | `DecryptOnly` | Reads encrypted data and writes none. `false` by default. | | `EventEncryptionField` | The top-level field of an event's data that is encrypted when the event is sent. `"encrypted"` by default. | | `Service` | An `EncryptionService` that encrypts in place of the default strategy. | **`EncryptionService`**, an abstract class: `Identifier`, which the envelope's `__STRATEGY__` carries; `Encrypt(ReadOnlySpan json)`, which returns the text of the `data` field; and `Decrypt(string data)`, which returns the UTF-8 JSON of the value. ## Sentry Package `Inngest.Sentry`. | Member | Purpose | | - | - | | `IInngestBuilder.AddSentryReporting(Action?)` | Adds the middleware that reports the errors of functions and steps to Sentry. Add it before other middleware, so that their work falls inside its scope. | **`SentryReportingOptions`**: `OnlyCaptureFinalAttempt` (default `true`), `CaptureStepErrors` (`false`), `DisableAutomaticFlush` (`false`) and `FlushTimeout` (2 seconds). See [Middleware](https://damikun.github.io/inngest-dotnet/guides/middleware.md#sentry) for the tags, the trace and the options. ## AI chat clients Package `Inngest.Extensions.AI`. The extension methods are in `Microsoft.Extensions.AI`. See [AI chat clients](https://damikun.github.io/inngest-dotnet/guides/ai.md). | Member | Purpose | | - | - | | `ChatClientBuilder.UseInngestSteps(Action?)` | Makes each call to the model inside a run a step: the messages and options are its input, the `ChatResponse` as JSON its result, and a replay returns the saved response without calling the model. A streaming call runs the whole stream in its step. Outside a run, and inside a step handler, calls pass through. | | `ChatClientBuilder.UseInngestFunctionInvocation(Action?, Action?)` | Adds a `FunctionInvokingChatClient`, as `UseFunctionInvocation` does, whose invocations of tools inside a run are steps named `tool `, with the arguments as input and `{"result": ...}` saved. A tool that throws is retried like any step, except for an `ArgumentException`, which fails its step at once. | | `ChatOptions.WithInngestStepId(string)` | Names the step of one call, in place of `chat`, for calls that reach the client in no fixed order, such as ones made after a request that finishes at varying times. The steps of its tools carry the name. The client removes the name before the options reach the model. | | `ChatOptions.WithInngestRealtimeStream(RealtimeTopic)` | Publishes the text of a streaming call to the topic while the model produces it, gathered into chunks of up to 1 KB, at most 1,000 to a stream. Failing to publish does not fail the call. | **`InngestInferChatClient(Uri endpoint, string model, string? apiKey = null, IReadOnlyDictionary? headers = null)`**, in the `Inngest` namespace, is an `IChatClient` whose calls are inference steps in the OpenAI chat completions format: Inngest makes the request, and no worker waits for it. It works inside a run only, sends `max_completion_tokens`, and fails for good on an answer that is an error or has no choices. A `strict` additional property of the options, or of a function, asks for strict structured outputs. **`InngestStepsOptions`**, in the `Inngest` namespace: `RecordInput` (default `true`) records the messages and options of a model call, or the arguments of a tool call, as the input of its step; without it the step is a plain run step. ## Source generator and analyzers Part of the `Inngest` package, which carries them in `analyzers/dotnet/cs` for the compiler to load. See [Source generator and analyzers](https://damikun.github.io/inngest-dotnet/guides/source-generator.md). | Member | Purpose | | - | - | | `IInngestBuilder.AddInngestFunctions()` | An extension method that the generator writes into each project that has turned generation on and has function classes, in the namespace `Inngest`, in a static class named after the assembly. It calls `AddFunction()` for every class with `[InngestFunction]` that derives from `InngestFunction` or `InngestFunction` and that generated code in the project can create and name, in the order of their names, and returns the builder. `internal` unless the project says `public`. | The MSBuild properties of the project that the generator and the analyzers read: | Property | Default | Purpose | | - | - | - | | `InngestGenerateFunctionRegistration` | `false` | `true` writes the method and reports the classes it leaves out (`INNGEST001` to `INNGEST005`). Nothing is written or reported until it is set. | | `InngestFunctionsMethodName` | `AddInngestFunctions` | The name of the generated method. | | `InngestFunctionsAccessibility` | `internal` | `public` or `internal`. | | `DisableInngestAnalyzers` | `false` | `true` removes everything the package runs in the compiler, the generator and both analyzers, from the build. | | `PublishAot`, `PublishTrimmed`, `JsonSerializerIsReflectionEnabledByDefault` | | Read by the JSON analyzer, which is active when reflection-based JSON is off. | The diagnostics: | Id | Severity | Reported when | | - | - | - | | [`INNGEST001`](https://damikun.github.io/inngest-dotnet/guides/source-generator.md#inngest001) | Warning | A class has `[InngestFunction]` but is not a function. | | [`INNGEST002`](https://damikun.github.io/inngest-dotnet/guides/source-generator.md#inngest002) | Warning | An abstract class has `[InngestFunction]`; it is not registered. | | [`INNGEST003`](https://damikun.github.io/inngest-dotnet/guides/source-generator.md#inngest003) | Warning | A generic class has `[InngestFunction]`; it is not registered. | | [`INNGEST004`](https://damikun.github.io/inngest-dotnet/guides/source-generator.md#inngest004) | Warning | A private, `protected`, `private protected` or file-local class, or one inside such a type, has `[InngestFunction]`; it is not registered. | | [`INNGEST005`](https://damikun.github.io/inngest-dotnet/guides/source-generator.md#inngest005) | Warning | A function class has no public constructor; it is not registered. | | [`INNGEST006`](https://damikun.github.io/inngest-dotnet/guides/source-generator.md#inngest006) | Warning | `InngestGenerateFunctionRegistration`, `InngestFunctionsMethodName` or `InngestFunctionsAccessibility` has a value the generator cannot use. | | [`INNGEST010`](https://damikun.github.io/inngest-dotnet/guides/source-generator.md#inngest010) | Warning | The event data or result of a function, or a type a typed step saves (`RunAsync`, `WaitForEventAsync`, `InvokeAsync`, `PublishAsync` and the others), is in no `JsonSerializerContext`, and reflection-based JSON is off. A code fix lists it. | | [`INNGEST011`](https://damikun.github.io/inngest-dotnet/guides/source-generator.md#inngest011) | Warning | The same type is anonymous, which no context can list. | ## Entity Framework Core outbox Package `Inngest.EntityFrameworkCore`. See [Transactional outbox](https://damikun.github.io/inngest-dotnet/guides/outbox.md). | Member | Purpose | | - | - | | `ModelBuilder.AddInngestOutbox(Action>?)` | Maps the `InngestOutbox` table in the model of a `DbContext`; the callback changes the mapping, for example its name or schema. In `Microsoft.EntityFrameworkCore`. | | `DbContextOptionsBuilder.UseInngestOutbox()` | Lets saves wake the dispatcher once they have committed, instead of at the next poll. Works wherever the options are built, with or without a service provider. In `Microsoft.EntityFrameworkCore`. | | `DbContextOptionsBuilder.UseInngestOutbox(IServiceProvider)` | The same, and fails when the options are built if `AddEntityFrameworkOutbox` was not called. In `Microsoft.EntityFrameworkCore`. | | `DbContext.AddInngestEvent(T data, string? id = null)` | Adds the data as an event named `T.EventName` to the unit of work, serialized with the SDK's serializer options, and returns the row. The id is generated unless given. In `Microsoft.EntityFrameworkCore`. | | `DbContext.AddInngestEvent(InngestEvent @event)` | The same for an `InngestEvent`, which keeps its `Id`, `User`, `Timestamp`, `Version` and the sessions of `Meta`. In `Microsoft.EntityFrameworkCore`. | **`InngestOutboxMessage`**, the entity of the table, with all times in UTC: `Id` (the key, and the id the event is sent under; at most 255 characters, as `Name` and `Version` are), `Name`, `Data`, `User` and `Sessions` (JSON text), `Version`, `Timestamp` (the event's own time, which the code may set), `CreatedAt` (when the row was added), `NextAttemptAt`, `Attempts`, `LastError`, `LeaseToken`, `LeaseExpiresAt` and `SentAt`. **`InngestOutboxOptions`**: | Member | Purpose | | - | - | | `DispatcherEnabled` | Runs the dispatcher in this process. `true` by default. | | `PollInterval` | How long the dispatcher waits for a wake-up before it looks anyway. 5 seconds by default. | | `BatchSize` | The most events one claim takes and one request sends. 100 by default. | | `LeaseDuration`, `SendTimeout` | How long a dispatcher owns the events it claimed (1 minute) and how long one request may take (20 seconds). The lease must be at least twice the timeout. | | `RetryDelay`, `MaxRetryDelay` | The wait after the first failed attempt, doubled with each failure, and its cap. 5 seconds and 5 minutes by default. | | `Retention`, `CleanupInterval`, `CleanupBatchSize` | How long sent events stay (1 day), how often older ones are deleted (5 minutes) and how many one `DELETE` removes (1000). | ## SignalR Package `Inngest.SignalR`, which also brings in `Inngest.AspNetCore`. See [SignalR](https://damikun.github.io/inngest-dotnet/guides/signalr.md). | Member | Purpose | | - | - | | `IServiceCollection.AddInngestSignalR(Action)` | Registers the bridge for `InngestRealtimeHub`. Call `AddSignalR` and `AddInngest` as well. In `Microsoft.Extensions.DependencyInjection`. | | `IServiceCollection.AddInngestSignalR(Action)` | Registers the bridge for a hub of your own, which injects `InngestRealtimeBridge`. The options are read at once, so a missing `Authorize` fails the app at startup. | | `IEndpointRouteBuilder.MapInngestRealtimeHub(string pattern)` | Maps `InngestRealtimeHub` and returns its `HubEndpointConventionBuilder`. In `Microsoft.AspNetCore.Builder`. | | `IEndpointRouteBuilder.MapInngestRealtimeHub(string pattern, Action)` | The same, with the options of the connections that `MapHub` takes, such as `CloseOnAuthenticationExpiration`. | **`InngestRealtimeHub`**, a sealed hub with `Subscribe(string channel, string[] topics)` and `Unsubscribe(string channel, string[] topics)`. **`InngestRealtimeBridge`** forwards the messages to the clients of `THub`. It sends to connections by id, never to a group, so that instances on a backplane deliver each message once. | Member | Purpose | | - | - | | `SubscribeAsync(HubCallerContext caller, string channel, IReadOnlyList topics, CancellationToken)` | `ValueTask`: asks `Authorize`, joins the caller and returns what it joined. Joining again changes nothing. Throws `HubException` when the request is incomplete, refused, over the limit, or the app is stopping. | | `SubscribeAsync(HubCallerContext caller, RealtimeTopic topic, CancellationToken)` | The same for a typed topic. | | `Unsubscribe(HubCallerContext caller, string channel, IReadOnlyList topics)` | Takes the caller out of what it joined with these arguments, or with the subscription `Authorize` returned. Leaving what it is not in changes nothing. | | `Unsubscribe(HubCallerContext caller, RealtimeTopic topic)` | The same for a typed topic. | **`InngestSignalROptions`**: | Member | Purpose | | - | - | | `Authorize` | `Func>`, required: returns the subscription to join, which may have fewer topics or another channel, or null to refuse. | | `MessageMethod` | The client method that receives each message as a string of JSON. `"Realtime"` by default. | | `ErrorMethod` | The client method that hears of a subscription the client lost, with the channel and topics it asked for and the reason: `"behind"`, `"send-failed"` or `"inngest-ended"`. The connection stays open. `"RealtimeError"` by default. | | `MaxSubscriptionsPerConnection` | How many subscriptions one connection may be in, counting each way it asked to join. 32 by default. | | `MaxQueuedMessagesPerConnection` | How many messages may wait for one connection. A connection that reaches it loses its subscriptions and is told. At least `MaxSubscriptionsPerConnection`. 512 by default. | **`RealtimeSubscribeRequest(HubCallerContext caller, RealtimeSubscription requested, IServiceProvider services, CancellationToken cancellationToken = default)`**, with `Caller`, `Requested`, `Services` (a scope of its own for each request) and `CancellationToken`. The bridge makes one for each client that asks to join, and a test can make one to call `Authorize` directly. ## Blazor Package `Inngest.Blazor`, for Blazor Server, WebAssembly and interactive Auto. See [Realtime in Blazor](https://damikun.github.io/inngest-dotnet/guides/blazor.md). **`RealtimeView`**, a component that draws nothing of its own. It subscribes after its first interactive render, never while prerendering, and unsubscribes when it is disposed. | Member | Purpose | | - | - | | `Topic` | The `RealtimeTopic` to watch. | | `Channel`, `Topics` | Several topics of a channel, instead of `Topic`. `TData` must then be `JsonElement`. | | `MaxMessages` | How many of the newest messages `RealtimeState.Messages` keeps. 100 by default. | | `OnMessage` | An `EventCallback>` called for each message, in order. | | `ThrowOnError` | Hands a failed subscription to the nearest `ErrorBoundary`. `false` by default. | | `ChildContent` | A `RenderFragment>`. | | `Restart()` | Subscribes again with the messages cleared, such as after a failure. | **`RealtimeState`**: `Connection`, `Error`, `Messages`, `Latest`, `LatestByTopic` and `TextByTopic`, the text of the newest stream of each topic. **`RealtimeConnectionState`** is `Idle`, `Connecting`, `Open`, `Reconnecting`, `Closed` or `Failed`. **`IRealtimeSource`** is what the component asks for, and what code outside components can inject: | Method | Returns | | - | - | | `SubscribeAsync(RealtimeTopic topic, CancellationToken)` | `IAsyncEnumerable>`: the topic's messages from now on | | `SubscribeAsync(string channel, IReadOnlyList topics, CancellationToken)` | `IAsyncEnumerable>` for several topics of a channel | | The same two with an `Action? onConnectionChanged` before the token | The same, and a source that can tell calls the callback with `Connecting`, `Open` and `Reconnecting`. An implementation writes these two. | Each side registers its own source. An app that registers both gets the one registered last. **`RealtimeRelayOptions`**: `Url`, which maps a channel and its topics to the address of the endpoint, and `SerializerOptions`, which read the data of typed topics. ## User context Packages `Inngest.UserContext` and `Inngest.UserContext.AspNetCore`. See [User context](https://damikun.github.io/inngest-dotnet/guides/user-context.md). | Member | Purpose | | - | - | | `IInngestBuilder.AddUserContext(Action?)` | Adds the middleware that attaches the allowed claims of the current user to the events an app sends and restores them in runs, and registers `ICurrentUser` and a scoped `ClaimsPrincipal`. Needs Data Protection, or an `IUserContextProtector`. Register it before middleware that needs the user. | | `IInngestBuilder.AddUserContextFromHttpContext()` | Package `Inngest.UserContext.AspNetCore`. Makes `HttpContext.User` the user of events sent while serving a request. | | `InvokeOptions.WithUser(FunctionContext)` | Extension method. Passes the user of the run to the function being invoked. Invocations do not pass through middleware, so they carry the user only when asked. | **`ICurrentUser`**: `Principal`, a `ClaimsPrincipal` that is anonymous without a user; `IsAuthenticated`; and `IssuedAt`, when the sender took the claims. It is the user of the run whose code is running, in every scope. **`UserContextOptions`**: | Member | Purpose | | - | - | | `Claims` | The claim types that are attached, matched exactly. `sub` and `ClaimTypes.NameIdentifier` by default. | | `Lifetime` | How long after the claims were taken a run can still start with them, judged by the later of its creation and its event's timestamp. Seven days by default. | | `MaxSize` | The largest size of the payload's JSON in UTF-8 bytes. 4096 by default; a larger user is not attached. | | `Propagate` | Whether the events a run sends carry its user. `true` by default. | | `EventFilter` | A `Func` that picks the events that carry the user. `null` means all. | | `OnInvalid` | An `InvalidUserContextAction`: `Anonymous` (default) runs without a user and logs a warning, `FailRun` fails the run. A failure handler never fails on its user. | **`IUserContextSource`**: `GetUserAsync(CancellationToken)` returns the `ClaimsPrincipal` an event sent outside a run is sent for, or null. **`IUserContextProtector`**: `Protect(string)` and `TryUnprotect(string, out string?)`, which turn the JSON of the claims into text for the event and back; ASP.NET Core Data Protection by default. ## Webhooks Package `Inngest.Webhooks`, which `Inngest.AspNetCore` brings in; `MapInngestWebhook` is in `Inngest.AspNetCore`. See [Webhooks](https://damikun.github.io/inngest-dotnet/guides/webhooks.md). **`WebhookProvider`** says how a source of webhooks is verified and how its deliveries become events. Its constructor `(string name, IWebhookVerifier verifier, Func createEvent)` takes any other source. The factories: | Factory | Event | | - | - | | `Stripe(string secret, TimeSpan? tolerance = null)` | `stripe/`, the Stripe event as data, its `id` as the id. Also with `IEnumerable` secrets. | | `GitHub(string secret)` | `github/`, the payload as data, `X-GitHub-Delivery` as the id; the name and the id are unsigned headers, and a delivery without the id is a 400. Also with `IEnumerable` secrets. | | `Shopify(string secret)` | `shopify/` with dots for slashes, the payload as data, `X-Shopify-Webhook-Id` as the id; the topic and the id are unsigned headers, and a delivery without the id is a 400. Also with `IEnumerable` secrets. | | `StandardWebhooks(string eventPrefix, string secret, TimeSpan? tolerance = null)` | `/`, the `data` of the payload as data, `webhook-id` or `svix-id` as the id. Also with `IEnumerable` secrets. | | `Hmac(string eventName, HmacWebhookOptions options, string secret)` | The one name, the payload as data, the header `options` names as the id, which a delivery must then have; no id without such a header. Also with `IEnumerable` secrets. | **`WebhookOptions`**, an immutable record: | Member | Purpose | | - | - | | `MaxBodySize` | The largest body accepted, in bytes; a larger request is answered with 413. 1 MiB by default. | | `SendTimeout` | How long to wait for Inngest to accept the event before answering 503. Four seconds by default. | | `Transform` | `Func`: changes the event before it is sent, or returns null to drop the delivery with 204. A `JsonElement` as the `Data` or `User` it returns is copied before the send. | **`WebhookDelivery`**, what a `Transform` and a provider's `createEvent` see: `Provider`, `DeliveryId`, `Payload` (a `JsonElement`, valid while the request is handled), `Services` and `GetHeader(string name)`. Its constructor `(string provider, string? deliveryId, JsonElement payload, IWebhookRequest? request = null)` is public, to try a `Transform` in a test. An event that cannot be sent is answered with a client error, not a 503: 413 when Inngest says it is too large, 400 for another client error of Inngest, for a refusal of the client and for a payload that cannot be written as JSON. See [Webhooks](https://damikun.github.io/inngest-dotnet/guides/webhooks.md#what-happens-to-a-request). **Verifiers.** Each takes a secret or several, so that a secret can be replaced without downtime, and checks a request with `Verify(IWebhookRequest request, DateTimeOffset now)`, which returns a `WebhookVerification` and never throws for what a request contains. | Type | Scheme | | - | - | | `StandardWebhooksVerifier` | `webhook-id`, `webhook-timestamp` and `webhook-signature`, or the `svix-` headers: HMAC-SHA256 of `{id}.{timestamp}.{body}`, keyed with the base64 after `whsec_`; only `v1` signatures; a tolerance of five minutes by default | | `StripeWebhookVerifier` | `Stripe-Signature`, `t=` and `v1=`: HMAC-SHA256 of `{t}.{body}`, keyed with the text of the secret; a tolerance of five minutes by default | | `GitHubWebhookVerifier` | `X-Hub-Signature-256`, `sha256=` and the hex HMAC-SHA256 of the body; delivery id from `X-GitHub-Delivery`, which the signature does not cover | | `ShopifyWebhookVerifier` | `X-Shopify-Hmac-Sha256`, the base64 HMAC-SHA256 of the body; delivery id from `X-Shopify-Webhook-Id`, which the signature does not cover | | `HmacWebhookVerifier` | Any HMAC of the body, with `HmacWebhookOptions` | **`HmacWebhookOptions`**, an immutable record: `SignatureHeader` (required), `Algorithm` (`HashAlgorithmName`, SHA-256 by default), `SignatureEncoding` (`HmacSignatureEncoding.Hex` or `Base64`), `SignaturePrefix`, `TimestampHeader`, `SignedContentPrefix`, `TimestampSeparator`, `Tolerance` and `DeliveryIdHeader`. To verify another source, implement **`IWebhookVerifier`**. **`IWebhookRequest`** is the request it sees, independent of the web framework: `Body` (the raw bytes, owned by the host and read in place), `GetHeader(string name)` and `Services`. **`WebhookVerification`** is a result: `Valid(string? deliveryId = null)` or `Invalid(string reason)`, with `IsValid`, `DeliveryId` and `Reason`; the default value is not valid. ## Test host Package `Inngest.Testing`, namespace `Inngest.Testing`. See [Testing functions](https://damikun.github.io/inngest-dotnet/guides/testing.md). | Member | Purpose | | - | - | | `InngestTestHost(IServiceCollection services)` | Builds the app's services from a copy of the collection, with nothing sent to Inngest. | | `RunAsync(string functionId, InngestEvent @event, StepMocks mocks, CancellationToken)` | Runs the function until it completes, fails for good or waits on a step without a mock. Also without `mocks`, and with an `IReadOnlyList` for a batch. | | `RunAsync(InngestEvent @event, StepMocks mocks, CancellationToken)` | The same for the function registered as `TFunction`, with the same overloads. | | `ExecuteStepAsync(string functionId, string stepId, InngestEvent @event, StepMocks mocks, CancellationToken)` | Runs the function until the step has run, or has been reached when Inngest carries it out. Also without `mocks`. | | `ExecuteStepAsync(string stepId, InngestEvent @event, StepMocks mocks, CancellationToken)` | The same for the function registered as `TFunction`. Also without `mocks`. | | `SentEvents` | Every event sent through the app's client since the host was created. | | `SentSignals` | Every signal sent through the app's client since the host was created. None resumes a run. | | `Published` | Every realtime message published since the host was created, as `PublishedMessage(RealtimeTopic Topic, JsonElement Data)` with `IsStream` for a stream, recorded with all its text. Tokens the host mints are placeholders, and subscribing fails. | | `Services` | The app's services, as functions resolve them. Disposing the host, which is `IAsyncDisposable` and `IDisposable`, disposes them. | **`StepMocks`** answers steps by id, and every method returns a new set: `Run(id, T result)`, `Run(id, Func handler)`, `RunThrows(id, exception)`, `Sleep(id)`, `AllSleeps()`, `WaitForEvent(id, received)`, `WaitForEventTimeout(id)`, `Invoke(id, T result)`, `Invoke(id, TResult result)`, `InvokeThrows(id, exception)`, `WaitForSignal(id, T data)`, `WaitForSignalTimeout(id)`, `SendSignal(id, runId)`, `Fetch(id, statusCode, body, headers)` and `Infer(id, T response)`. A `Run` mock also answers an AI wrap step. **`TestRun`**: `RunId`, `FunctionId`, `Status`, `Output`, `GetOutput()`, `Error`, `Steps`, `GetStep(id)`, `WaitingOn`, `SentEvents`, `SentSignals`, `Published` and `FailureHandler`; `ToString()` describes the outcome. `Status` is a `TestRunStatus`: `Completed`, `Failed`, `Waiting` or `Stopped`. **`TestStep`**: `Id`, `HashedId`, `Type`, `Op`, `Options`, `Status`, `Attempts`, `Mocked`, `Output`, `GetOutput()`, `Error` and `Errors`. `Status` is a `TestStepStatus`: `Pending`, `Completed` or `Failed`. ## Test containers Package `Inngest.Testing.Containers`, namespace `Inngest.Testing.Containers`. See [Testing with containers](https://damikun.github.io/inngest-dotnet/guides/testing-with-containers.md). It follows the Testcontainers module pattern: a builder, a container and a configuration. **`InngestServerBuilder(string image)`** builds an `InngestServerContainer` for the dev server. There is no default image; the module is tested with `inngest/inngest:v1.49.0`. It takes an `IImage` as well, and has the methods of every Testcontainers builder. | Member | Purpose | | - | - | | `WithSelfHosting()` | Runs `inngest start`, the self-hosted server, in place of the dev server. | | `WithSigningKey(string)`, `WithEventKey(string)` | Sets the keys of the self-hosted server. Keys left out are generated. The signing key must be hexadecimal digits in pairs. | | `WithAppUrl(Uri)`, `WithAppUrl(string)` | Has the server sync the app at this address when it starts and retry every second until the app answers. A loopback address becomes `host.docker.internal`. | | `WithConnectGateway(int? port = null)` | Publishes the Connect gateway on the same port inside the container and on the host, and waits for it. A free port when none is given, kept from other processes until the container starts. | | `WithFastTiming(bool = true)` | The timing flags that make runs finish quickly: a sync retried and a step retried after a second, the queue polled every 50 ms. On by default. | | `InngestServerBuilder.InngestPort` | The port of the server's API inside the container, 8288. | **`InngestServerContainer`**, a `DockerContainer`: | Member | Purpose | | - | - | | `GetBaseAddress()` | The address of the server's API as the test reaches it, as a `Uri`. `GetConnectionString()` returns it as text. | | `Configure(InngestOptions)` | Sets an app's options to use the server: dev mode and the address, or cloud mode with the keys and the address. Replaces addresses set in code. | | `GetSdkEnvironment()` | The same settings as `INNGEST_*` variables, for a process that inherits others: `INNGEST_DEV` (the dev server's address, or `0` for the self-hosted server), `INNGEST_BASE_URL`, `INNGEST_API_BASE_URL` and `INNGEST_EVENT_API_BASE_URL`, and `INNGEST_SIGNING_KEY` and `INNGEST_EVENT_KEY` for the self-hosted one. | | `SyncAsync(Uri appUrl, CancellationToken)` | Has the server register the app that listens at this address. A loopback address, or `0.0.0.0` or `[::]`, goes out as `host.docker.internal`; any other address as given. The request names the kind of server (`dev` or `cloud`) and is signed for the self-hosted server. Throws when the app does not register. | | `IsDevServer`, `SigningKey`, `EventKey`, `ConnectGatewayPort` | Which server it is, the keys of the self-hosted server (null for the dev server) and the gateway's port (null without one). | | `StartAsync(CancellationToken)` | Starts the container. A server that does not become ready fails with its log in the exception. | **`InngestServerConfiguration`** holds what the builder keeps for the container: `SelfHosted`, `SigningKey`, `EventKey`, `AppUrls`, `ConnectGateway`, `ConnectGatewayPort` and `FastTiming`, next to the settings of any container. ## ASP.NET Core test factory Package `Inngest.Testing.Containers.AspNetCore` for .NET 10, namespace `Inngest.Testing.Containers`. See [An app under WebApplicationFactory](https://damikun.github.io/inngest-dotnet/guides/testing-with-containers.md#an-app-under-webapplicationfactory). | Member | Purpose | | - | - | | `InngestWebApplicationFactory(InngestServerContainer inngest, string servePath = "/api/inngest")` | A `WebApplicationFactory` that runs the app on Kestrel, on all interfaces and a free port, and applies `inngest.Configure` to the app's options after the app's own. The container must be running when the app starts. | | `StartAsync(CancellationToken)` | Starts the app and has the container sync it, so the server knows its functions when this returns. | | `GetAppUrl()` | The address of the app's Inngest endpoint as the test reaches it. | | `CreateClient()`, `CreateClient(WebApplicationFactoryClientOptions)` | Declared again, so that a client reaches the app at `localhost` and its port, also with options that have no base address. `CreateDefaultClient` gets the same address. | | `Container` | The container the app is configured for. | ## Templates and tool Packages `Inngest.Templates` and `dotnet-inngest`. They have no C# API: the first holds `dotnet new` templates and the second is a .NET tool. See [Templates and tool](https://damikun.github.io/inngest-dotnet/guides/templates-and-tools.md). **Templates**, installed with `dotnet new install Inngest.Templates`: | Short name | Project SDK | References | | - | - | - | | `inngest-web` | `Microsoft.NET.Sdk.Web` | `Inngest.AspNetCore`, at the version of the template package | | `inngest-worker` | `Microsoft.NET.Sdk.Worker` | `Inngest.Connect`, at the version of the template package, and `Microsoft.Extensions.Hosting` | | Option | Templates | Meaning | | - | - | - | | `-n`, `--name` | both | The project name, and the app id in kebab case. | | `-f`, `--framework` | both | `net10.0` (the default) or `net8.0`. | | `--http-port` | `inngest-web` | The port of the `http` launch profile. A free port from 5000 to 5300 by default. | | `--no-restore` | both | Skips the restore after the project is made. | **`dotnet inngest`**, installed with `dotnet tool install --global dotnet-inngest` (the command is `dotnet-inngest`): | Command | Purpose | | - | - | | `dotnet inngest dev [options] [-- ]` | Downloads the Inngest CLI release on first use, checks its SHA-256 and runs `inngest dev`. | | `dotnet inngest --version` | Prints the version of the tool and of the CLI it is pinned to. | | `dotnet inngest --help` | Prints the usage. | | Option of `dev` | Meaning | | - | - | | `-u`, `--sdk-url ` | The app's Inngest endpoint, passed to the CLI. Without it, and without `--no-discovery`, the tool reads the `http` address of the first profile that runs the project in `Properties/launchSettings.json` and appends `/api/inngest`. | | `--project ` | The folder or project file whose launch settings are read. The current folder by default. | | `--cli-version ` | Runs another release of the CLI. Its download is checked against the `checksums.txt` of the release; the pinned version against checksums built into the tool. | Other options go to `inngest dev` as they are, in order. `INNGEST_DOTNET_CACHE` sets the folder the CLI is kept in. The exit code is the CLI's; the tool's own are 1 when the CLI cannot be had (a request failed, or a download does not match its SHA-256), 2 for a command line it cannot read and 130 when Ctrl+C stops a download. --- # Function options builder > Build a function's options in the browser and see them as attributes and in code, the JSON the SDK sends to Inngest when the app syncs, and which attribute sets each option. Web page: https://damikun.github.io/inngest-dotnet/explainers/function-options/ Everything Inngest controls about a function, from its triggers to flow control, goes in `FunctionOptions`, which the function class can also carry as attributes. When Inngest syncs the app, the SDK turns those options into JSON. Pick a preset or change the options, and the output shows the C# in both forms and that JSON. Like the other pages, it shows attributes first unless you picked **Code first** in one of their tabs. An interactive version is on [the web page](https://damikun.github.io/inngest-dotnet/explainers/function-options/). ## How the output is made The builder writes the JSON the same way the SDK's `RegistrationWriter` does: the same properties in the same order, with durations as Inngest time strings (`90` minutes becomes `"1h30m"`) and options that are not set left out, so Inngest applies its defaults. A few details worth knowing: - A function's id on the wire is `-`. - `retries.attempts` holds the number of retries, not attempts. Leave `Retries` empty to get Inngest's default of 4 retries. - `Match` on a cancellation becomes the expression `event. == async.`. - A cron trigger with a time zone is sent with a `TZ=` prefix. - A class that implements `IFailureHandler` registers a second function, `-failure`, triggered by `inngest/function.failed`. The checks are the ones `FunctionOptionsValidator` makes when the app starts, and the SDK, like the builder, reports all the problems at once. ## Attributes As attributes, the options sit on the function class, and `AddFunction()` reads them into `FunctionOptions` when the app starts, so both forms sync to the same JSON and go through the same checks. Each attribute sets one member: | Attribute | Sets | | - | - | | `[InngestFunction("id", Name, Retries, Idempotency)]` | `Id`, `Name`, `Retries`, `Idempotency` | | `[EventTrigger("name", If)]`, `[EventTrigger(If)]` | an `EventTrigger` in `Triggers` | | `[CronTrigger("schedule", Timezone, Jitter)]` | a `CronTrigger` in `Triggers` | | `[Concurrency(limit, Key, Scope)]` | a `ConcurrencyLimit` in `Concurrency` | | `[Throttle(limit, "period", Burst, Key)]` | `Throttle` | | `[RateLimit(limit, "period", Key)]` | `RateLimit` | | `[Debounce("period", Key, Timeout)]` | `Debounce` | | `[BatchEvents(maxSize, "timeout", Key, If)]` | `BatchEvents` | | `[Priority("run")]` | `Priority` | | `[Singleton(mode, Key)]` | `Singleton` | | `[CancelOn("name", If, Match, Timeout)]`, `[CancelOn(If, Match, Timeout)]` | a `CancelOn` in `CancelOn` | | `[Timeouts(Start, Finish)]` | `Timeouts` | The trigger, concurrency and cancellation attributes can be repeated. Durations are time strings such as `"30s"`, `"5m"` or `"1h30m"`. `Middleware` and `Checkpointing` have no attributes; set them with `AddFunction(options => options with { ... })`, which starts from the attributes. Options passed as `AddFunction(new FunctionOptions { ... })` replace the attributes. An attribute of your own derives from `FunctionOptionsAttribute`, as all of these but `[InngestFunction]` do; [Functions and triggers](https://damikun.github.io/inngest-dotnet/guides/functions-and-triggers.md#attributes-of-your-own) shows how. The site's build checks this page against the SDK: the snippet check compiles both forms of every preset, registers each in a real app and compares the JSON the SDK sends with the JSON shown here. [Flow control](https://damikun.github.io/inngest-dotnet/guides/flow-control.md) explains each option, and [Functions and triggers](https://damikun.github.io/inngest-dotnet/guides/functions-and-triggers.md) covers ids, names and triggers. --- # Flow control timeline > See how concurrency, throttling, rate limiting and debouncing change when the runs for a burst of events start. Web page: https://damikun.github.io/inngest-dotnet/explainers/flow-control/ The same fifteen events arrive in bursts over a minute. Pick a kind of flow control and change its settings to see which runs start at once, which wait, which are skipped and which are merged. An interactive version is on [the web page](https://damikun.github.io/inngest-dotnet/explainers/flow-control/). ## Reading the timeline Each dot on the top line is an event. Each bar below is a run, from when it starts to when it ends, and the line from an event leads to the run that handles it. A slanted line means the run started later than its event arrived. The model is simple on purpose: one key, every run is a single step, and timings are in seconds. Inngest's scheduler works with more detail, for example concurrency limits steps rather than whole runs, and rate limiting uses the generic cell rate algorithm with the period starting at the first event. Inngest has its own [flow control simulator](https://www.inngest.com/docs/durable-execution/flow-control/simulator) for exact behavior. ## In code Each of these is one attribute on the function class, or one property of `FunctionOptions`: ### Attributes ```csharp [Concurrency(2)] [Throttle(3, "10s", Burst = 1)] [RateLimit(3, "10s")] ``` A debounce takes a period and an optional timeout: ```csharp [Debounce("5s", Timeout = "15s")] ``` ### Code first ```csharp Concurrency = [new ConcurrencyLimit(2)], Throttle = new Throttle(3, TimeSpan.FromSeconds(10)) { Burst = 1 }, RateLimit = new RateLimit(3, TimeSpan.FromSeconds(10)), ``` A debounce takes a period and an optional timeout: ```csharp Debounce = new Debounce(TimeSpan.FromSeconds(5)) { Timeout = TimeSpan.FromSeconds(15) }, ``` [Flow control](https://damikun.github.io/inngest-dotnet/guides/flow-control.md) covers every option, including keys that give each customer a limit of its own. --- # Roadmap > What each release of the Inngest .NET SDK added, from the test host of the first preview to realtime, durable endpoints, webhooks and hosting on AWS Lambda and Azure Functions in 1.0.0, and what comes next. Web page: https://damikun.github.io/inngest-dotnet/roadmap/ The SDK covers what most workflows need: steps, sleeps, waits for events, parallel steps and fan-out, invocations, flow control, retries, middleware, realtime, request signing with key rotation, and Connect workers, on .NET 8 and .NET 10 and under Native AOT. The packages are on [NuGet](https://www.nuget.org/packages/Inngest). ## New in 1.0.0 1.0.0 brings every package to its first major version, and adds 16 packages to the 5 that 0.1.0 had. From here on, each package is checked against 1.0.0, so a change that would break code built against it fails the build. Durable endpoints are the exception: they stay marked experimental and may still change. - **A health check for apps that serve functions.** `AddInngest` on the health checks builder tells a readiness probe whether the app has its keys and Inngest answers a request that carries the signing key. It keeps the answer for 30 seconds, and with `CheckSync` it also asks Inngest whether the app is synced. See [Health checks](https://damikun.github.io/inngest-dotnet/guides/deploying.md#health-checks). - **AI calls as steps.** `AIWrapAsync` runs your own model call as a step that the Inngest UI shows with its input, and a rerun with an input edited there hands the handler the edited one. An input edited for `InferAsync` now reaches the request too. - **Realtime.** Steps publish messages and stream text, such as a model's reply, once each; typed topics check what is published; `IInngestRealtime` publishes from anywhere, receives messages in .NET as an `IAsyncEnumerable` and mints tokens for the TypeScript SDK's `useRealtime`; and `MapInngestRealtime` relays messages to browsers as server-sent events, one subscription to Inngest for all the pages that watch a channel. See [Realtime](https://damikun.github.io/inngest-dotnet/guides/realtime.md). - **Streaming.** `InngestOptions.Streaming`, or `INNGEST_STREAMING=true`, keeps a long call open behind proxies and load balancers that close idle connections. See [Behind a proxy](https://damikun.github.io/inngest-dotnet/guides/configuration.md#behind-a-proxy). - **Sessions.** Events carry the sessions they belong to, a run's sessions are in `context.Sessions`, and what the run sends or invokes inherits them. See [Sessions](https://damikun.github.io/inngest-dotnet/guides/sending-events.md#sessions). - **A test host for every step.** Mocks for waits for signals, sent signals, fetch and inference steps; AI wrap steps take run mocks; signals and realtime messages are recorded like events. - **An Aspire integration.** `Inngest.Aspire.Hosting` runs the Inngest dev server, or a self-hosted server that signs its requests, as a container of an Aspire AppHost. It links the server's UI in the dashboard, syncs the apps of the AppHost and gives projects, containers and Connect workers the settings they need. See [Aspire](https://damikun.github.io/inngest-dotnet/guides/aspire.md). - **A scope per step.** `RunScopedAsync` runs a step in a dependency injection scope of its own, created when the handler starts and disposed when it ends, so a `DbContext` is new for every step and attempt and nothing it tracks reaches the next one. The guide covers one `SaveChangesAsync` per step, transactions and execution strategies, and idempotency keys for steps that can run again. See [Scoped steps](https://damikun.github.io/inngest-dotnet/guides/scoped-steps.md). - **Sentry and encryption packages.** `Inngest.Sentry` reports the errors of functions and steps to Sentry, tagged with the app, the function, the run and the event. `Inngest.Encryption` encrypts event data, step results and function output in the format of the TypeScript SDK's encryption middleware, so .NET and TypeScript services read each other's data. See [Middleware](https://damikun.github.io/inngest-dotnet/guides/middleware.md#encryption). - **Durable endpoints.** An ASP.NET Core endpoint whose handler runs steps like a function. The caller gets the response directly, and Inngest takes the run over only when a step has to wait or fails. `UseInngestDurableEndpoints` and `WithDurableEndpoint`, or `[DurableEndpoint]` on a controller, set it up. Inngest calls the feature beta, so the .NET API is marked experimental and may change. See [Durable endpoints](https://damikun.github.io/inngest-dotnet/guides/durable-endpoints.md). - **Tests against a real Inngest server.** `Inngest.Testing.Containers` is a Testcontainers module that runs the dev server, or a self-hosted server with signing and event keys, in Docker from your tests, points an app at it and has it sync the app, with the Connect gateway if you want one. `Inngest.Testing.Containers.AspNetCore` runs an ASP.NET Core app under `WebApplicationFactory` on a real port for it, on .NET 10. See [Testing with containers](https://damikun.github.io/inngest-dotnet/guides/testing-with-containers.md). - **Chat clients as steps.** `Inngest.Extensions.AI` makes the model calls and the tool calls of a Microsoft.Extensions.AI `IChatClient` steps of the run, so a replay or a retry of a later step never calls the model or a tool twice. See [AI chat clients](https://damikun.github.io/inngest-dotnet/guides/ai.md). - **A transactional outbox.** `Inngest.EntityFrameworkCore` saves events in the same Entity Framework Core transaction as your data, and a hosted dispatcher sends them afterwards, each under the id it got when it was added. See [Transactional outbox](https://damikun.github.io/inngest-dotnet/guides/outbox.md). - **A source generator and analyzers.** A project that sets `InngestGenerateFunctionRegistration` gets `AddInngestFunctions()`, which registers its function classes without reflection, and an analyzer reports the types a Native AOT app has not listed in its `JsonSerializerContext`, with a code fix that adds them. Both come with the `Inngest` package. See [Source generator and analyzers](https://damikun.github.io/inngest-dotnet/guides/source-generator.md). - **Templates and a dev server command.** `dotnet new inngest-web` and `dotnet new inngest-worker`, from the `Inngest.Templates` package, make a project with a typed event, a function and dev mode in its Development settings. `dotnet inngest dev`, from the `dotnet-inngest` tool, downloads the Inngest CLI, checks its SHA-256 and runs the dev server for the app, whose address it reads from the launch settings. See [Templates and tool](https://damikun.github.io/inngest-dotnet/guides/templates-and-tools.md). - **Fan-out over a list.** `MapAsync` runs a step for each item of a list, with a limit on how many run at once, and returns the results in the order of the items. `ForEachAsync` does the same without results, `MapOutcomesAsync` runs every item and gives each its outcome, and `MapInChunksAsync` hands a list too long for one run to child runs in chunks. See [Fan-out over a list](https://damikun.github.io/inngest-dotnet/guides/fan-out.md). - **Webhooks.** `Inngest.Webhooks` verifies the signatures of webhooks from Stripe, GitHub, Shopify, Standard Webhooks senders such as Clerk and Resend, and any HMAC. `MapInngestWebhook` sends each delivery as an event whose id is the delivery id, so a redelivery within 24 hours starts no second run. See [Webhooks](https://damikun.github.io/inngest-dotnet/guides/webhooks.md). - **The user behind a run.** `Inngest.UserContext` carries selected claims of the signed-in user who sent an event into the runs it starts, protected with ASP.NET Core Data Protection, and a function reads them as `ICurrentUser` or `ClaimsPrincipal`. `Inngest.UserContext.AspNetCore` takes the user from `HttpContext.User`. See [User context](https://damikun.github.io/inngest-dotnet/guides/user-context.md). - **AWS Lambda and Azure Functions.** `Inngest.AwsLambda` serves functions from a Lambda function behind API Gateway or a function URL with `InngestLambdaHandler`, also as a Native AOT executable. `Inngest.AzureFunctions` serves them from an HTTP-triggered function of the isolated worker with `ServeInngestAsync`. See [Running on AWS Lambda](https://damikun.github.io/inngest-dotnet/guides/aws-lambda.md) and [Azure Functions](https://damikun.github.io/inngest-dotnet/guides/azure-functions.md). - **Realtime in Blazor and SignalR.** `Inngest.Blazor` shows realtime messages with `RealtimeView` in Blazor Server, WebAssembly and interactive Auto. `Inngest.SignalR` forwards them to the clients of a SignalR hub, with one subscription to Inngest shared by the clients of a channel. See [Realtime in Blazor](https://damikun.github.io/inngest-dotnet/guides/blazor.md) and [SignalR](https://damikun.github.io/inngest-dotnet/guides/signalr.md). - **Docs for AI assistants.** Every page of this site has a Markdown copy, `llms.txt` lists them and `llms-full.txt` holds them all, so a coding assistant can read the docs as text. A guide lists the names that differ from the TypeScript SDK and the rules that keep a function correct when it replays. See [AI coding assistants](https://damikun.github.io/inngest-dotnet/guides/ai-assistants.md). ## New in 0.1.0 The first stable release of `Inngest`, `Inngest.AspNetCore`, `Inngest.Connect`, `Inngest.Testing` and `Inngest.OpenTelemetry`, with the fixes of a second code review: - Middleware that finishes async work after the call has answered no longer makes the SDK spin on the thread pool, and hooks can wait on the tasks a function started, as step handlers can. - `off` and `no` in Inngest's environment variables, such as `INNGEST_DEV=off`, read as false. - A call that finished answers even when disposing its services throws. - Checkpointed steps and runs with deeply nested outputs read back. - Batch timeouts are written in hours, which Inngest can parse. - A Connect worker's instance id defaults to the full host name, as in the TypeScript SDK. ## New in 0.1.0-preview\.3 - **Fixes from a code review.** A step that awaits a task the function started before it no longer hangs the call, and a task the function started, such as one a singleton caches, completes even after the call has answered. Connect keeps flushing replies over HTTP whatever the HTTP client throws, and no longer gives up a lease Inngest has just renewed. Deeply nested step results read back, and the endpoint gets past an app's fallback authorization policy. - **Safer defaults.** The event key stays out of the HttpClient logs, an address without a scheme gets `https` outside a private network, and an unsigned sync that takes its URL from the request logs a warning. - **Every configuration problem at once.** The SDK checks every function at startup and lists all problems, each naming its function. It also refuses duplicate triggers, as Inngest does, and accepts a concurrency limit of 0, which means no limit. - **Attributes of your own.** The trigger and flow control attributes derive from `FunctionOptionsAttribute`, so an app can write an attribute that bundles options several functions share. - **Every step type in telemetry**, with names for signal, fetch and AI steps too. - **A test host closer to Inngest.** A function's own middleware runs around mocked steps and recorded sends, the ids of one send sort in send order, and calls carry a job id. - **Checked packages.** Each package is validated against the previous release, so a breaking API change fails the build, and the packages depend on exactly the Inngest of their own version. ## New in 0.1.0-preview\.2 - **Typed events.** An event's data record implements `IInngestEventData` and carries the event's name, so sends, triggers, waits and cancellations name the type instead of repeating a string. - **Attributes on function classes.** `[InngestFunction]`, `[EventTrigger]`, `[CronTrigger]`, `[Concurrency]`, `[CancelOn]` and the other flow control attributes keep a function's id, triggers and limits on its class. `AddFunction()` reads them, and options written in code work as before. - **Typed results and invoke by class.** `InngestFunction` gives a function a result type, and `InvokeAsync` invokes it by its class, with the compiler checking the result. - **Settings from `appsettings.json`.** `AddInngest(builder.Configuration.GetSection("Inngest"))` reads the `Inngest` section, and `INNGEST_*` variables still win over it. Like the attributes and typed events, it works under Native AOT. - **The test host by class.** `RunAsync` runs a function by its class, and `StepMocks.Invoke` checks what a mocked invoke returns. ## New in 0.1.0-preview\.1 - **A test host, `Inngest.Testing`.** Unit-test a function the way Inngest runs it, call by call, with mocks for steps, sleeps, waits and invokes. - **OpenTelemetry, `Inngest.OpenTelemetry`.** Traces for calls and steps, and metrics for runs, retries and durations, without writing the middleware yourself. - **Connect health checks.** The worker's connection state and a health check for readiness probes. - **Race mode for parallel steps.** A race resumes as soon as its first step finishes, for races such as approved or rejected. - **A runs client.** Read a run's status and output, list the runs of an event, and cancel runs from your own code. - **Signals, durable fetch and AI inference** as steps, and sending events or signals to another environment. - **Checkpointing, opt-in.** Consecutive steps run in one request and are saved to Inngest in the background, with fewer requests per run. - **Smaller additions.** Event access in middleware, middleware for one function, payload transforms, typed system events, the cause of a failed step, cron jitter and readable step names in the Inngest UI. ## Next - **Guides** for versioning, idempotency, loops, expressions and local development. - **Checkpointing on by default**, once it has more time against Inngest Cloud. - **Durable endpoints out of experimental**, with resumes for endpoints that require authorization and support in the test host. - **A validation middleware package**, built on the payload transforms. ## After that - More Roslyn analyzers, and `defer`. Ideas and pull requests are welcome on [GitHub](https://github.com/damikun/inngest-dotnet/issues). --- # Deployment readiness Web page: https://damikun.github.io/inngest-dotnet/internals/readiness/ Rendered from [`docs/readiness.md`](https://github.com/damikun/inngest-dotnet/blob/main/docs/readiness.md) in the repository when the site was built. Before recommending the SDK for containers and the cloud, we ran it the way production runs it: in Docker against Inngest's own server image, in dev mode and in signed cloud mode, under sustained load and for millions of invocations. It passed every check, and the two bugs the checks turned up are fixed. ## At a glance | Check | Result | | - | - | | Docker, dev mode | The sample app syncs over the Docker network and all 9 flow checks pass | | Docker, cloud mode | All 11 checks pass with signed requests, including the refusal of unsigned and forged calls | | Load | 8,000 of 8,000 runs completed with the right output in dev mode, and 8,000 of 8,000 in cloud mode. The app stayed under 60 MiB of memory; the server, not the app, set the pace | | Memory | Flat over 8 million engine invocations and 1.6 million signed HTTP invocations; work abandoned on a pending step is collected | | Concurrency | 11 stress tests with up to 4,000 concurrent invocations each get their own answers | | Tests | 3,735 unit tests and 351 end-to-end tests against the Inngest server in Docker | The checks ran on 2026-10-29 on an Apple M3 Max with Docker Desktop, the `mcr.microsoft.com/dotnet/aspnet:10.0` image and `inngest/inngest:v1.49.0`; the test counts are from 2026-10-31. The raw data of every check, from the flow results to the load and memory samples and the benchmark reports, is in [`docs/results`](https://github.com/damikun/inngest-dotnet/tree/main/docs/results). ## Two bugs found and fixed - **Connect kept abandoned executions alive.** Every execution got the worker's own long-lived token, so a function left waiting on a step stayed reachable until the worker stopped. Each execution now gets a token of its own that is disposed when it ends. - **The HTTP endpoint reserved memory on the caller's word.** It sized the first body buffer from an unauthenticated `Content-Length`, up to 4 MB per request. It now reserves at most 64 KB and grows the buffer as the body arrives. Both fixes come with tests that fail without them. One race is left in the Connect worker, in theory only: if the worker stalled for 5 seconds at one exact point, a reply would wait for its acknowledgement instead of being sent over HTTP. ## Run the sample in Docker [`samples/Inngest.Sample`](https://github.com/damikun/inngest-dotnet/tree/main/samples/Inngest.Sample) has a Dockerfile and a compose file that run the app with `inngest/inngest:v1.49.0`. From that folder: ```bash # Dev mode: the dev server signs nothing and accepts any event key. docker compose --profile dev up --build -d ``` ```bash # Cloud mode: inngest start signs every request; the server and the app read the same two keys. export INNGEST_SIGNING_KEY=$(openssl rand -hex 32) export INNGEST_EVENT_KEY=$(openssl rand -hex 16) docker compose --profile cloud up --build -d ``` The server's API and UI are published on `127.0.0.1:18288`. For your own app, the [deploying guide](https://damikun.github.io/inngest-dotnet/guides/deploying.md) covers keys, health checks and shutdown. ## Benchmarks The SDK's own work per call is measured in microseconds; [benchmarks.md](https://damikun.github.io/inngest-dotnet/internals/benchmarks.md) has the indicative figures. A full benchmark run on dedicated hardware is coming. --- # Benchmarks Web page: https://damikun.github.io/inngest-dotnet/internals/benchmarks/ Rendered from [`docs/benchmarks.md`](https://github.com/damikun/inngest-dotnet/blob/main/docs/benchmarks.md) in the repository when the site was built. `benchmarks/Inngest.Benchmarks` measures the work the SDK does on every call from Inngest: parsing the request, replaying memoized steps, writing the answer and checking the signature. It uses [BenchmarkDotNet](https://benchmarkdotnet.org) and touches no network. The figures below are indicative. They come from one run on a laptop that was busy with other work, so treat them as orders of magnitude. A full benchmark run on dedicated hardware is coming. | Work | Mean | Allocated | | - | -: | -: | | Run the first step of a run | 5.1 µs | 6.4 KB | | Replay 10 memoized steps | 14.4 µs | 17 KB | | Replay 100 memoized steps | 107 µs | 118 KB | | Parse a call that carries 100 step results | 33.3 µs | 27.6 KB | | Verify the signature of a 1 KB request | 1.18 µs | 320 B | | Write the sync body of 20 functions | 22.5 µs | 16.3 KB | | Send one event through the client | 1.49 µs | 3.8 KB | Each memoized step adds about 1.1 µs and 1.1 KB on top of about 4.5 µs and 6 KB per call. A middleware that overrides nothing adds 8 to 9% to a call. ## Running them ```bash dotnet run -c Release --project benchmarks/Inngest.Benchmarks -- --filter '*' --job short ``` `--job short` runs the 22 cases in about two minutes; leave it out for BenchmarkDotNet's slower, more precise default job. Run them in Release on an idle machine and compare results only with runs from the same machine. The figures above used the default job on 2026-10-29: an Apple M3 Max, .NET 10.0.12, BenchmarkDotNet 0.15.8, with a load average that fell from 29.6 to 3.2 during the run. The full reports are in [`docs/results/benchmarks`](https://github.com/damikun/inngest-dotnet/tree/main/docs/results/benchmarks). --- # Design Web page: https://damikun.github.io/inngest-dotnet/internals/design/ Rendered from [`docs/design.md`](https://github.com/damikun/inngest-dotnet/blob/main/docs/design.md) in the repository when the site was built. This document describes how the .NET SDK is put together and the wire protocol it speaks. It is written for people working on the SDK; the README covers usage. inngest-dotnet is a community Inngest SDK for .NET. It speaks the same protocol as Inngest's official SDKs, checked against the server's [SDK spec](https://github.com/inngest/inngest/blob/main/docs/SDK_SPEC.md) and the server's Go code, and compared with the official SDKs for TypeScript ([inngest-js](https://github.com/inngest/inngest-js) v4.26.2), Go, and Kotlin and Java. Where the spec and inngest-js disagree, the SDK does what the server actually relies on; those cases are listed in [Spec deviations](https://damikun.github.io/inngest-dotnet/internals/design.md#spec-deviations). The behavior that differs from inngest-js on purpose is in [Differences from the TypeScript SDK](https://damikun.github.io/inngest-dotnet/internals/design.md#differences-from-the-typescript-sdk). It is built for .NET rather than translated to it. Functions are classes resolved from dependency injection, with their options as attributes, typed events and results, a dependency injection scope per step and settings from configuration. Packages connect it to ASP.NET Core, Azure Functions, AWS Lambda, Aspire, Entity Framework Core, SignalR, Blazor, Microsoft.Extensions.AI, OpenTelemetry, Sentry and Testcontainers. The rows marked .NET only in [What is supported](https://damikun.github.io/inngest-dotnet/internals/design.md#what-is-supported) are the features particular to this SDK. ## Protocol Inngest talks to an app through one HTTP endpoint (by default `/api/inngest`). The method decides the action: | Method | Caller | Purpose | | - | - | - | | `GET` | Inngest, dev server discovery, anyone | Introspection and health check | | `PUT` | Inngest, the dev server, a deploy script | Sync: register the app's functions | | `POST` | Inngest executor | Call: run a function or one of its steps | Every response to a verified request (and every response in dev mode) carries `X-Inngest-SDK: inngest-dotnet:v`. The server treats a 2xx without it as "not an SDK" and retries, which matters behind proxies. Answers to unverified requests in cloud mode leave out the SDK headers on purpose, so they tell an anonymous caller nothing. ### Sync (PUT) The `X-Inngest-Sync-Kind` request header picks the kind of sync. **Out-of-band** (header absent or `out_of_band`): the SDK posts its configuration to `POST {api}/fn/register?deployId=` with `Authorization: Bearer ` and `X-Inngest-Expected-Server-Kind` copied from the PUT's `X-Inngest-Server-Kind`. It then answers the PUT with `200 {"message":"Successfully registered","modified":}` or `500 {"message":"","modified":false}`, plus `X-Inngest-Sync-Kind: out_of_band`. Out-of-band PUTs are accepted unsigned, as in the TypeScript SDK, because the SDK only calls back the configured Inngest. **In-band** (header `in_band`): the PUT body is `{"url":""}` and must be signed in cloud mode. The SDK answers `200` with `X-Inngest-Sync-Kind: in_band` and the configuration in the body, so Inngest does not need to reach the app twice. `InngestOptions.AllowInBandSync = false` forces out-of-band syncs, and `AllowUnsignedSync = false` (`INNGEST_ENABLE_UNAUTHED_SYNC=false`) makes cloud mode refuse unsigned ones. The configuration (the register body, and the `functions` of an in-band reply): ```json { "url": "https://example.com/api/inngest", "deployType": "ping", "appName": "shop", "appVersion": "1.4.0", "sdk": "dotnet:v1.0.0", "v": "0.1", "framework": "aspnetcore", "capabilities": { "trust_probe": "v1" }, "functions": [{ "id": "shop-send-welcome-email", "name": "Send welcome email", "triggers": [ { "event": "user/signed.up", "expression": "event.data.plan == 'pro'" }, { "cron": "TZ=Europe/Prague 0 9 * * 1-5" } ], "steps": { "step": { "id": "step", "name": "step", "runtime": { "type": "http", "url": "https://example.com/api/inngest?fnId=shop-send-welcome-email&stepId=step" }, "retries": { "attempts": 3 } }}, "concurrency": [{ "limit": 10, "key": "event.data.accountId", "scope": "fn" }], "throttle": { "limit": 5, "period": "1m", "burst": 2, "key": "event.data.accountId" }, "rateLimit": { "limit": 1, "period": "4h", "key": "event.data.accountId" }, "idempotency": "event.data.orderId", "debounce": { "period": "5s", "key": "event.data.accountId", "timeout": "1m" }, "batchEvents": { "maxSize": 50, "timeout": "5s", "key": "event.data.tenant" }, "priority": { "run": "event.data.vip ? 120 : 0" }, "cancel": [{ "event": "user/deleted", "if": "event.data.userId == async.data.userId", "timeout": "1d" }], "timeouts": { "start": "10m", "finish": "1h" }, "singleton": { "mode": "skip", "key": "event.data.userId" } }] } ``` Notes on the fields: - A function's id on the wire is `-`, not slugified. `name` is required by the server and defaults to the function id. - `retries.attempts` holds the number of retries, not attempts. When omitted the server uses 4 retries (5 attempts); the maximum is 20. - Durations are time strings (`"1h30m"`), formatted from `TimeSpan` with whole-second resolution. Batch timeouts are parsed with Go's `time.ParseDuration`, so they never use `d` or `w` units. - `idempotency` is turned into a 24h rate limit by the server and replaces any `rateLimit`. - A function with a failure handler registers a second function `-failure`, triggered by `inngest/function.failed` with `event.data.function_id == '-'` and one retry. ### Introspection (GET) Returns `schema_version: "2024-05-24"`. In cloud mode with a valid signature the body is the authenticated form (app id, origins, hashed keys, function count, mode, SDK language and version); in cloud mode without one the SDK answers `401`, as the TypeScript SDK does. Dev mode verifies nothing, so it always gets the unauthenticated form (function count, mode, which keys are set). ### Call (POST) Query parameters: `fnId` is the composite function id; `stepId` is `step` for a normal call or the hashed id of a planned step to run. The body (keys arrive sorted, because Inngest canonicalizes it before signing): ```json { "event": { "name": "user/signed.up", "data": { "userId": 1 }, "user": {}, "id": "01J...", "ts": 1705586504000 }, "events": [ "...all events of a batch, or just event..." ], "steps": { "e7d8a2f140845095749d60246ff1110c9d01d76a": { "data": "A" }, "2c4ac3...": { "error": { "name": "Error", "message": "boom", "stack": "..." } }, "8f1a2b...": null, "93bd77...": { "name": "order/paid", "data": {}, "ts": 1705586505000 }, "51c0de...": { "input": [] } }, "ctx": { "run_id": "01J...", "attempt": 0, "max_attempts": 5, "stack": { "stack": ["e7d8a2f1...", "2c4ac3..."], "current": 2 }, "disable_immediate_execution": false, "use_api": false, "fn_id": "" }, "version": 2 } ``` A memoized step is read as: an object with only `data` is a result, only `error` is a final failure, only `input` is not a result (input of a step still in progress), and anything else, including `null` and a raw event, is the result itself. Sleeps memoize `null`; `waitForEvent` memoizes the matched event or `null` on timeout. When `ctx.use_api` is true the server trimmed a large payload, and the SDK fetches the events from `GET {api}/v0/runs/{run id}/batch` and the steps from `GET {api}/v0/runs/{run id}/actions`, authenticated with the hashed signing key. Any failure there answers `500`. ### Responses to calls | Status | Meaning | Headers | | - | - | - | | `200` | The function returned; the body is its output as JSON | | | `206` | The body is an array of step operations | `X-Inngest-No-Retry` when a step errored | | `500` | Retriable function error; body `{"name","message","stack"}` | `X-Inngest-No-Retry: false`, optional `Retry-After` | | `400` | Final function error (non-retriable, uncaught step failure, or last attempt) | `X-Inngest-No-Retry: true` | | `401` | Signature missing or invalid in cloud mode | | Every call response also carries `X-Inngest-Req-Version: 2`. In cloud mode it is signed with `X-Inngest-Signature` using the key that verified the request. Step operations (`id` is the hashed step id): | `op` | Sent when | Shape | | - | - | - | | `StepPlanned` | A `run` or `sendEvent` step is found but not executed | `{id, op, name, displayName, opts: {type}}` | | `StepRun` | A step ran and succeeded | `{id, op, name, displayName, data, timing}` | | `StepError` | A step threw and has attempts left | `{id, op, name, displayName, error}` | | `StepFailed` | A step threw on its last attempt or threw `NonRetriableException` | `{id, op, name, displayName, error}` | | `Sleep` | `SleepAsync`, `SleepUntilAsync` | `{id, op, name: "", opts: {duration}}` | | `WaitForEvent` | `WaitForEventAsync` | `{id, op, name: "", opts: {event, timeout, if}}` | | `InvokeFunction` | `InvokeAsync` | `{id, op, opts: {function_id, payload: {data, user}, timeout}}` | Step ids are the lowercase hex SHA-1 of the id the code passed. A repeated id gets `:1`, `:2`, ... appended before hashing, in call order (`my-step` = `e7d8a2f1...` is the spec's example for `my-step-id`). ### Deciding what to answer For `stepId=step`, the SDK replays the function, feeding it memoized results, until the function returns, throws, or waits on steps that are not memoized. Then: - If exactly one new step was found, it is a `run` step and `ctx.disable_immediate_execution` is false, the SDK runs it right away and answers `206 [StepRun]` (or `StepError`/`StepFailed`). - Otherwise it answers `206` with all new steps. The server runs each `StepPlanned` step in its own call with `stepId=` and sets `disable_immediate_execution` for the rest of the parallel section. - If the function returns while new steps are pending (fired but not awaited), the steps are reported, not the return value. For `stepId=`, the SDK replays until it reaches that step, runs it and answers with one operation. If the step does not show up within 10 seconds, it answers `500` with `X-Inngest-No-Retry: false` (the server has no `StepNotFound` op). Memoized results are handed back in the order recorded in `ctx.stack.stack`, so races between parallel steps replay the way they first resolved. If the code asks for steps in an order the stack cannot satisfy, the SDK logs that the function changed between calls and falls back to the earliest memoized step that was asked for. ### Retries Every queue item (the first call, each planned step, each discovery call) gets `retries + 1` attempts, counted by `ctx.attempt` from zero. A function error is final when it is a `NonRetriableException`, an uncaught `StepFailedException`, or happens on the last attempt; the SDK answers `400` then. A `RetryAfterException` adds `Retry-After` (seconds, or an RFC 3339 time), which the server clamps to between 1 second and 24 hours. After the final failure Inngest emits `inngest/function.failed` with `{function_id, run_id, error, event}`, which is what failure handlers run on. ### Signing Requests from Inngest carry `X-Inngest-Signature: t=&s=`. `s` is HMAC-SHA256 over the raw body followed by `t`, keyed with the signing key minus its `signkey--` prefix, used as UTF-8 text (not decoded from hex). Requests older or newer than 5 minutes are rejected. With a fallback key configured (`INNGEST_SIGNING_KEY_FALLBACK`), a signature that fails with the current key is checked against the fallback, so a key can be rotated without downtime. Calls to the Inngest API authenticate with `Authorization: Bearer signkey--` and are retried once with the fallback key on `401` or `403`. In dev mode nothing is verified or signed. ### Events `POST {event api}/e/{event key}` with a JSON array of `{name, data, user, id, ts, v}`. The response is `{"ids": [...], "status": 200}`; the body's `status` decides success. The SDK sends `X-Inngest-Event-Id-Seed: ,` once per send and keeps it across retries, so a retried send creates the same event ids. Sends are retried up to 5 attempts with exponential backoff. ### Modes and configuration | Setting | Environment variable | Default | | - | - | - | | `IsDev` | `INNGEST_DEV` (`1`/`true`, or the dev server URL) | cloud mode | | `EventKey` | `INNGEST_EVENT_KEY` | none; any key works against the dev server | | `SigningKey`, `SigningKeyFallback` | `INNGEST_SIGNING_KEY`, `INNGEST_SIGNING_KEY_FALLBACK` | required in cloud mode | | `Environment` | `INNGEST_ENV` | the key's environment | | `BaseUrl`, `ApiBaseUrl`, `EventApiBaseUrl` | `INNGEST_BASE_URL`, `INNGEST_API_BASE_URL`, `INNGEST_EVENT_API_BASE_URL` | `https://api.inngest.com`, `https://inn.gs`, or `http://localhost:8288` in dev mode | | `ServeOrigin`, `ServePath` | `INNGEST_SERVE_ORIGIN`, `INNGEST_SERVE_PATH` | the URL the request arrived on | | `AllowInBandSync` | `INNGEST_ALLOW_IN_BAND_SYNC` | `true` | | `AllowUnsignedSync` | `INNGEST_ENABLE_UNAUTHED_SYNC` | `true` | Options set in code win over environment variables. Variables are read through `IConfiguration`, so they can also come from `appsettings.json` or any other configuration source. `AddInngest(IConfiguration)` reads the options from a section such as `Inngest` as well, as the lowest layer: code wins over the `INNGEST_*` variables, which win over the section, which wins over the defaults. The section is read key by key rather than through the configuration binder, so it works trimmed and under Native AOT. ## .NET architecture ### Packages | Package | Contents | | - | - | | `Inngest` | Client, function definitions, step tools, execution engine, middleware, signing, the framework-agnostic serve handler. Depends only on `Microsoft.Extensions.*` abstractions. It also carries a source generator, analyzers and a code fix (see [Source generator and analyzers](https://damikun.github.io/inngest-dotnet/internals/design.md#source-generator-and-analyzers)), which are not packages of their own. | | `Inngest.AspNetCore` | `MapInngest`, which adapts ASP.NET Core requests to the serve handler, and `AddInngest` on `IHealthChecksBuilder`, a readiness check for apps that serve functions. | | `Inngest.AwsLambda` | `InngestLambdaHandler`, which adapts the events of API Gateway REST and HTTP APIs and Lambda function URLs to the serve handler, in a managed runtime function or a Native AOT executable. Depends on `Amazon.Lambda.APIGatewayEvents` and `Amazon.Lambda.Core` only. | | `Inngest.AzureFunctions` | `ServeInngestAsync`, which adapts the request of an HTTP-triggered function of the Azure Functions isolated worker to the serve handler. Depends on `Inngest.AspNetCore` and, of the worker, only `Microsoft.Azure.Functions.Worker.Core`. | | `Inngest.Connect` | `AddConnect`, a hosted worker that receives calls over an outbound WebSocket instead of an HTTP endpoint. | | `Inngest.Aspire.Hosting` | `AddInngest` and `AddInngestSelfHosted`, which run the Inngest dev server or a self-hosted server as a container of an Aspire AppHost, `WithApp`, which tells the server where an app that serves functions is, and `WithReference`, which gives a project the `INNGEST_*` settings the SDK reads. Depends on `Aspire.Hosting` only, not on `Inngest`. | | `Inngest.Testing` | `InngestTestHost`, which runs functions in memory on the real execution engine for unit tests, with mocked steps, retries and replays. | | `Inngest.OpenTelemetry` | `AddInstrumentation`, a middleware with spans for calls and executed steps and metrics for runs, steps, retries and durations. Depends on `OpenTelemetry.Api` only. | | `Inngest.Encryption` | `AddEncryption`, a middleware that encrypts event data, step results and function output in the format of `@inngest/middleware-encryption`. XSalsa20, Poly1305 and BLAKE2b come from `BouncyCastle.Cryptography`, which is managed code. | | `Inngest.Sentry` | `AddSentryReporting`, a middleware that reports the errors of functions and steps to Sentry, with tags for the call and the run's trace. Depends on `Sentry` only. | | `Inngest.Extensions.AI` | `UseInngestSteps` and `UseInngestFunctionInvocation`, stages for the `ChatClientBuilder` of Microsoft.Extensions.AI that make each model call and each tool call of a run an AI wrap step, and `InngestInferChatClient`, a chat client of inference steps in the OpenAI chat format. Depends on `Microsoft.Extensions.AI`. | | `Inngest.EntityFrameworkCore` | `AddEntityFrameworkOutbox`, a transactional outbox: events are rows saved with the app's data through `DbContext.AddInngestEvent`, and a hosted dispatcher sends them. Depends on `Microsoft.EntityFrameworkCore.Relational`, version 8 for net8.0 and 10 for net10.0. See [Transactional outbox](https://damikun.github.io/inngest-dotnet/internals/design.md#transactional-outbox). | | `Inngest.Testing.Containers` | A Testcontainers module (`InngestServerBuilder`, `InngestServerContainer`, `InngestServerConfiguration`) that runs the dev server or the self-hosted server (`inngest start`) in Docker for tests, with the settings that point an app at it and a call that has it sync the app. Depends on `Testcontainers` and `Inngest`'s public options only. | | `Inngest.Testing.Containers.AspNetCore` | `InngestWebApplicationFactory`, a `WebApplicationFactory` that runs the app on Kestrel against the container. Depends on `Microsoft.AspNetCore.Mvc.Testing`, so it is a package of its own and targets net10.0 only. | | `Inngest.Templates` | The `inngest-web` and `inngest-worker` templates for `dotnet new`. A template package: files, no assembly. | | `dotnet-inngest` | A .NET tool, `dotnet inngest dev`, that downloads the Inngest CLI, checks it and runs the dev server. Depends on nothing but the framework. | | `Inngest.SignalR` | `AddInngestSignalR`, `MapInngestRealtimeHub` and `InngestRealtimeBridge`, which forward realtime messages to SignalR clients through `IHubContext`: clients join through a hub method guarded by a callback, share one subscription per channel and topics, and get each message on a queue of their own, by connection id and never through a group, so that instances on a backplane deliver once. Reads the realtime subscriber of `Inngest` through `InternalsVisibleTo`, takes `RealtimeSubscription` from `Inngest.AspNetCore`, and uses the SignalR of the ASP.NET Core shared framework. | | `Inngest.Blazor` | `RealtimeView`, a headless Blazor component, and `IRealtimeSource`, the realtime messages as an async stream, with a source for the server (`AddInngestRealtimeServerSource`, over `IInngestRealtime`) and one for WebAssembly (`AddInngestRealtimeRelaySource`, which reads a `MapInngestRealtime` endpoint). A Razor class library that references `Microsoft.AspNetCore.Components.Web` as a package, not the shared framework, so it runs in the browser. | | `Inngest.UserContext` | `AddUserContext`, a middleware that attaches selected claims of the current user to the events an app sends, protected with ASP.NET Core Data Protection, and restores them in runs as `ICurrentUser` and `ClaimsPrincipal`. Depends on `Microsoft.AspNetCore.DataProtection`, which has no framework reference, so workers without ASP.NET Core use it. | | `Inngest.UserContext.AspNetCore` | `AddUserContextFromHttpContext`, which takes the user of an event sent while serving a request from `HttpContext.User`. Uses the ASP.NET Core shared framework. | | `Inngest.Webhooks` | Verifiers for Standard Webhooks (Svix, Clerk, Resend), Stripe, GitHub, Shopify and any HMAC, the providers that turn a verified delivery into an event, and the pipeline behind `MapInngestWebhook` (which is in `Inngest.AspNetCore`). Depends on `Inngest` only, and uses `System.Security.Cryptography` for every signature. | `Inngest.AspNetCore`, `Inngest.AwsLambda`, `Inngest.Blazor`, `Inngest.Connect`, `Inngest.SignalR`, `Inngest.Testing`, `Inngest.Encryption`, `Inngest.EntityFrameworkCore`, `Inngest.Extensions.AI` and `Inngest.UserContext` use internals of `Inngest` through `InternalsVisibleTo`: the serve handler, the execution engine, the registry and the resolved settings, of which `Inngest.Encryption` only reads the serializer options, `Inngest.EntityFrameworkCore` those and the checks the client makes on an event it sends, `Inngest.Extensions.AI` only asks which invocation the calling code runs in, and so whether it can make a step, and `Inngest.UserContext` reads the settings, the registry and the run a piece of code belongs to. `Inngest.Blazor` and `Inngest.SignalR` use the realtime subscriber, and `Inngest.Blazor` also its message reader and server-sent events parser. Internals can change in any release, so the packages ship together and each depends on exactly the `Inngest` of its own version (a target in `src/Directory.Build.targets` writes the exact range). `Inngest.OpenTelemetry` and `Inngest.Sentry` use only the public middleware API, and `Inngest.Testing.Containers` only `InngestOptions`; `Inngest.Testing.Containers.AspNetCore` reaches one internal helper of `Inngest.Testing.Containers` through `InternalsVisibleTo`, and depends on exactly its version too. `Inngest.AzureFunctions` uses the serve handler of `Inngest` and the `InngestEndpoint` of `Inngest.AspNetCore` in the same way, so it depends on exactly the `Inngest.AspNetCore` of its own version too, as `Inngest.AspNetCore` does on the `Inngest.Webhooks` whose internal receiver `MapInngestWebhook` uses. Package validation compares every package with the last release on nuget.org, so `dotnet pack` fails on a change that would break code built against it; `PackageValidationBaselineVersion` in `src/Directory.Build.props` is raised after each release. Every package with an assembly is `IsAotCompatible` except four. The two container packages: Testcontainers and `Microsoft.AspNetCore.Mvc.Testing` are built on reflection, and the packages are for test projects, which are neither trimmed nor compiled ahead of time. `Inngest.EntityFrameworkCore`: the constructors of EF Core's `DbContext` require unreferenced and dynamic code. And `Inngest.Aspire.Hosting`, below. `Inngest.Aspire.Hosting` is a different kind of package: it runs in the AppHost, not in the app, so it does not reference `Inngest` and is not pinned to its version. It is not `IsAotCompatible`, because `Aspire.Hosting` declares neither trimming nor Native AOT support and an AppHost is never published as a native binary. Aspire reserves the `Aspire.` prefix on nuget.org, hence the name. ### Code layout - **Namespaces.** Public types are in `Inngest`, whatever folder they live in, so one `using Inngest;` brings in the whole API. Internal types are in the namespace of their folder, such as `Inngest.Execution` or `Inngest.Registration`. Extension methods on a framework type are in that type's namespace (`Microsoft.Extensions.DependencyInjection`, `Microsoft.AspNetCore.Builder`, `OpenTelemetry.Trace`), and extension methods on Inngest types are in `Inngest`. `Inngest.Aspire.Hosting` follows Aspire instead: its resource type is in `Aspire.Hosting.ApplicationModel` and its extension methods are in `Aspire.Hosting`, the two namespaces an AppHost imports. - **Files.** Every public type has a file named after it; the generic forms of a type share it or use `OfT`, as in `InngestEventOfT.cs`. Internal helper types may share the file of the type they serve. A class too large to read in one file is split into partial files named after their concern, such as `Invocation.Steps.cs`, `Invocation.Execution.cs` and `Invocation.Answers.cs`. - **Comments.** Public types and members carry a `/// `, which IDEs show. Everything else has `//` comments that say why, not what. - **Tests.** The folders of `tests/Inngest.Tests` follow the areas of the source (`Steps`, `Functions`, `Middleware`, `Registration` and so on), and the integration tests are grouped by the Inngest server fixture they run against. ### Public API The API follows .NET conventions rather than the shape of the TypeScript SDK: - `services.AddInngest(options => ...)`, or `services.AddInngest(configuration.GetSection("Inngest"))`, registers everything and returns an `IInngestBuilder` for `AddFunction()`, `AddFunction(FunctionOptions)`, `AddFunction(FunctionOptions, handler)` and `AddMiddleware()`. `app.MapInngest("/api/inngest")` serves the endpoint. - Functions are classes deriving from `InngestFunction`, or `InngestFunction` for a typed result, resolved from a DI scope per invocation, or delegates. `FunctionOptions` and the flow control records (`ConcurrencyLimit`, `Throttle`, `RateLimit`, `Debounce`, `BatchEvents`, `Priority`, `CancelOn`, `FunctionTimeouts`, `Singleton`) are immutable records with `TimeSpan` durations. - A function class can carry its options as attributes (`[InngestFunction]`, `[EventTrigger]`, `[CronTrigger]`, `[Concurrency]`, `[Throttle]`, `[CancelOn]` and so on), one per `FunctionOptions` member, with durations as time strings. All but `[InngestFunction]` derive from `FunctionOptionsAttribute` and map themselves onto the options in `Configure`, so users can write their own. `AddFunction()` reads them once at registration, with `GetCustomAttributes` on the type it names, which stays trim and AOT safe: it starts from `[InngestFunction]` and applies the others in `Order`, then in declaration order. The result goes through the same startup validator. Explicit `FunctionOptions` replace the attributes, and `AddFunction(configure)` adjusts them. There is no assembly scanning: the source generator writes `AddInngestFunctions()`, which calls `AddFunction()` for each class with `[InngestFunction]`, in a project that turns it on. The generated code does not call `Apply` itself, so the attributes are still read when the app starts and not at compile time. - Event data records implement `IInngestEventData`, whose static abstract `EventName` (and static virtual `EventVersion`) let `SendAsync`, `Trigger.Event`, `WaitForEventAsync`, `CancelOn.For` and `SendEventAsync` take the name from the type without reflection. Every string form stays. - `step.InvokeAsync` finds the function's id in the registry by its class; `IReturns`, which `InngestFunction` implements, lets the compiler check the result type. - `FunctionContext` gives the typed event and batch, run id, attempt, step tools, a replay-safe `ILogger` and the invocation's `IServiceProvider`. - Step tools are an interface, `IStepTools`, so user code can be unit tested with a fake: `RunAsync`, `SleepAsync`, `SleepUntilAsync`, `WaitForEventAsync`, `InvokeAsync`, `SendEventAsync`. Members added later have default implementations, and convenience overloads are extension methods over those six. Parallel steps are plain `Task.WhenAll`/`Task.WhenAny`. - Fan-out over a list is extension methods over those six: `MapAsync`, `ForEachAsync` and `MapOutcomesAsync` run a step per item with a window of items in flight, and `MapInChunksAsync` and `MapOutcomesInChunksAsync` send long lists to child runs of a `FanOutFunction` class. See [Fan-out](https://damikun.github.io/inngest-dotnet/internals/design.md#fan-out). - `RunScopedAsync` is a run step whose handler runs in a dependency injection scope of its own. The invocation makes the scope from its services (a sibling of the invocation's scope, as `IServiceScopeFactory` makes them) when the handler starts and disposes it, asynchronously, when the handler ends, so a `DbContext` is new for every attempt and step, and a memoized, planned or mocked step makes none. It is a `RunStep` with a wrapped handler, so Inngest, middleware (`StepType.Run`) and the test host see an ordinary run step, and middleware keeps seeing the invocation's scope in `FunctionContext.Services`. The typed overloads are extension methods that resolve one service with `GetRequiredService()`. A scoped service that throws when it is disposed is logged and does not fail the step, so disposal must not do work that can fail. - Errors are exceptions: `NonRetriableException`, `RetryAfterException`, and `StepFailedException` thrown at a step's call site when the step failed for good. `InngestException` covers failures talking to Inngest. - Failure handlers implement `IFailureHandler` on the function class, or pass `onFailure` with a delegate. - Middleware derives from `InngestMiddleware` and overrides only the hooks it needs: observers (`OnRunStartAsync`, `OnMemoizationEndAsync`, `OnRunCompleteAsync`, `OnRunErrorAsync`, `OnStepStartAsync`, `OnStepCompleteAsync`, `OnStepErrorAsync`) and wrappers (`WrapFunctionAsync`, `WrapStepAsync`, `WrapStepHandlerAsync`, `WrapSendEventAsync`). Instances are resolved per call, so they can hold state for the call, as in v4. A run's calls can reach any instance of the app, so nothing in them lasts the run. ### Request path ``` Kestrel -> MapInngest -> InngestEndpoint (pooled body) -> InngestCommHandler GET -> introspection PUT -> FunctionRegistry.WriteRegistration -> in-band body, or IInngestApi.RegisterAsync POST -> signature check -> FunctionRegistry.TryGet(fnId) -> ExecutionEngine.ExecuteAsync -> ServeResponse ``` Azure Functions reaches the same handler through a function of the app that calls `request.ServeInngestAsync()`. With the isolated worker's ASP.NET Core integration, the function runs inside a request of the worker's Kestrel, whose `HttpContext` is in `FunctionContext.Items`, and `InngestEndpoint.HandleAsync` answers through it, streaming included. With the built-in HTTP model, the body is a `MemoryStream` that already holds the request and is used in place, and the response is copied into an `HttpResponseData`, which the worker sends to the host when the function returns, so a call cannot be streamed. `InngestCommHandler` knows nothing about ASP.NET Core; it works on `IServeRequest` and returns `ServeResponse`, so other hosts, and the Connect worker, reuse it. Startup data (resolved settings, the function registry) is immutable and read without locks. Request and response bodies live in pooled buffers; the body's raw bytes are what the signature is computed over and what `JsonDocument` parses, without copies. ### Execution engine Each call replays the function from the top. The interesting part is knowing when the user's code has gone as far as it can in this call. The TypeScript SDK approximates that by waiting out a fixed number of microtask turns. The .NET engine runs the user's code on a private `SynchronizationContext` for the invocation (`InvocationScheduler`), so every continuation of the function is queued there. When that queue is empty and the function has not finished, the function is waiting on something: a step that will not complete in this call, or real I/O whose continuation will arrive later. That is a precise signal, not a timeout, and it keeps `Task.WhenAll` batching working, and `Task.WhenAny` races replaying with the same winner, with no special API. For Inngest to call the function again as soon as the first step of a race finishes, the race runs in race mode (`ParallelAsync`). One limit: a function that keeps queueing work without ever waiting (a tight `Task.Yield` loop) is cut off after 100,000 continuations per round so that a call always ends; memoized results may then be handed out before the function idles. From there the engine follows the TypeScript SDK: 1. Step tools hash the id and return a task the engine controls. 2. Memoized results are handed out one at a time, in `ctx.stack.stack` order, each followed by running the function until it idles again. 3. When nothing memoized is left to hand out, the steps found since the last report decide the answer, as described in [Deciding what to answer](https://damikun.github.io/inngest-dotnet/internals/design.md#deciding-what-to-answer). 4. Steps that were found but not run are left pending. Their tasks are never faulted or cancelled, so `catch` and `finally` blocks in user code do not run for them in this call, matching the TypeScript SDK. User code that leaves the scheduler (`ConfigureAwait(false)`, `Task.Run`) still works: step calls made from other threads are posted back to the scheduler, so engine state is only touched by one logical thread and needs no locks. Such code can make the SDK report fewer steps at once. With an id that parallel branches share, it can also change which branch gets which result, since a repeated id is numbered in the order the steps reach the scheduler, as the branches' own I/O can do too. The step tools register a step when it is called, a fetch with a body that is not buffered included: it holds every decision of the call until the body has been read, so that it is reported with the steps found with it. Blocking on a step task (`.Result`) deadlocks the invocation, as it would on any single-threaded context. Two rules keep tasks the function starts from hanging, as they would not on the JavaScript event loop: - While a step handler or a middleware hook runs, the engine keeps running what is queued on the scheduler, without deciding anything, so either can await a task the function started before the step. - Once the call has answered, work posted to the scheduler, and work still queued, runs on the thread pool instead of being dropped. A task the function started may outlive the call, such as one a singleton caches, or a `SemaphoreSlim` wait whose `finally` releases it. The abandoned function's own steps still never complete. ### Fan-out `MapAsync` and its siblings are extension methods over `IStepTools`, written against nothing but `RunAsync` and `InvokeAsync`, so a fake of the step tools runs them too. - **Ids.** An item is the step `[]` and a chunk is `[-]`. The engine appends `:` to a repeated id, so `:` could be taken by the second use of `` in a run. The ids here end in `]`, which that suffix never does, and the index is the run of digits after the last `[`, so two items or an item and a chunk never share an id. What can still repeat is an id the code passes twice, and the engine numbers that as ever. A chunk has both of its ends in its id so that a run replayed with another chunk size does not hand one chunk the result of another; it invokes every chunk again instead. - **The window.** `FanOutRun` starts one lane per unit in flight, as the workers of `Parallel.ForEachAsync` do: a lane starts a unit, waits for it, and takes the next. The engine hands memoized results back in the order the run recorded them, one at a time, and each hand-back lets its lane start the next unit, so a replay starts the units in the order the run first did, whichever order the results arrived in. Under the default mode Inngest calls the function again only when every step of a group has finished, so the window moves on in groups; under race mode after each step, so it slides. The engine tells that the function is idle on its scheduler, so the lanes run there: they await with `ConfigureAwait(true)` on purpose, since `false` would move their continuations to the thread pool, and the next unit would start while the engine decides what to report. A caller that left the scheduler, after `ConfigureAwait(false)` or inside `Task.Run`, has `StepTools.RunOnScheduler` queue the start of the lanes there with its execution context, as one piece of work, so that the whole first window is found at once and a fan-out behaves as it does called from the scheduler. The alternative, lanes that continue on the thread that completes a step (`ContinueWith` with `ExecuteSynchronously`), fixes the awaits but leaves the first window to be registered step by step from the pool thread, which the engine can interleave with hand-backs: in 60,000 replays of calls off the scheduler it gave one window in another order and, once, another first failure, both from `Task.Run`; queuing the start gave none in as many. A continuation that is not inlined would also run on the pool. A fake of the step tools has no scheduler, and the lanes then start where the caller is. The answer is taken where the run is decided, not when the awaiting code resumes. - **Calls.** A list of `n` items in windows of `w` takes one call per window of two or more items that finds its items, one call per item, one call for a last window of one item that runs in the call that finds it, and one that returns: seven items in windows of three are 10 calls. In race mode it is `2n + 1`. Every call replays the function and carries the results so far, which is why lists beyond a few hundred items go to chunks. - **Failures.** A fail-fast run starts nothing after the first failure it sees and throws it without waiting for the units in flight, since a planned step cannot be recalled. The failure it sees first is the one Inngest recorded first, because hand-backs follow the record. A step that failed for good is the only thing that counts as an item's failure; anything else a unit throws ends the run with that exception. - **Chunks.** The function of a chunk is a class, because the code of an item cannot travel to another run. The parent invokes it by class with a `FanOutChunk` as the event data and reads `FanOutOutcome[]` back; the function never fails because of an item, it reports it, so the parent can name the item that failed. The outcomes are an array because the engine writes a function's output by its runtime type, so a source-generated context has to list the type that is written. Sizes follow the limits of Inngest, 32 MiB for a run's state among them, and what the dev server enforces: a run fails once 1,000 steps have completed, so the guards refuse 1,000 items, chunks or chunk size and more, without retries, and an SDK response is capped at 8 MiB, which carries all the chunks planned in one call and the outcomes a chunk's run ends with. See the guide for what was tried. ### Serialization The SDK's own wire types use System.Text.Json source generation, one `JsonSerializerContext` per area. User types (event data, step results, function output) go through `InngestOptions.SerializerOptions`, which defaults to `JsonSerializerDefaults.Web` with reflection when reflection is enabled. Apps that trim or use Native AOT add their own source-generated context to its `TypeInfoResolverChain`, and an analyzer of the `Inngest` package finds the types that context lacks. Step results are round-tripped through JSON even on the call that ran them, the same as every later replay sees them. Packages that save types the app does not own, such as `Inngest.Extensions.AI` with `ChatResponse`, write them to a `JsonElement` with the options of the library the types come from and hand the SDK only the element, so the app's options need no contracts for them. ### Source generator and analyzers `src/Inngest.SourceGeneration` holds an incremental source generator and two analyzers, and `src/Inngest.SourceGeneration.CodeFixes` the code fix. Neither is a package. `src/Inngest/Inngest.csproj` builds both without referencing them and packs the two assemblies in `analyzers/dotnet/cs`, with `buildTransitive/Inngest.props` and `Inngest.targets`. They ship in the package of the SDK because the generated code calls `AddFunction()` of the same release and every project that defines a function references `Inngest`, which is where System.Text.Json and `Microsoft.Extensions.Logging` put their generators too, next to the attributes they read. `martinothamar/Mediator` ships its generator as a package of its own, because only the app that hosts the mediator needs it and libraries use the abstractions alone; that is not the shape here, where a library with functions needs the generator as much as the app does. `buildTransitive` and not `build`, because an app mostly reaches `Inngest` through `Inngest.AspNetCore` or `Inngest.Connect`, and the assets of `build` only reach the project that references the package itself. Everything in the package reaches every existing user on upgrade, so nothing it does may change a build that compiles today. The registration is **opt-in**: `InngestGenerateFunctionRegistration` is false until a project sets it, and the generator then writes nothing and the class diagnostics are not reported. Without that, a project that registers a generic class closed by hand, or a private class from the class that holds it, would have warnings (errors, under warnings as errors) about code that works, and a helper of its own called `AddInngestFunctions` would become ambiguous the moment it had a class with `[InngestFunction]`. The JSON analyzer is limited by what it checks: it is silent unless the project turns reflection-based JSON off, and then it reports types that would fail at run time. `DisableInngestAnalyzers` removes everything the package runs in the compiler, for the build that one of them breaks. - **Roslyn.** Both assemblies target `netstandard2.0` (RS1041) and `Microsoft.CodeAnalysis.CSharp` 4.8.0, the compiler of the first .NET 8 SDK and Visual Studio 17.8, the oldest that can build for `net8.0`. A component loads into its own Roslyn version or a newer one, so one build serves every compiler the packages can be used with, where System.Text.Json's generator ships builds for Roslyn 3.11, 4.0 and 4.4 under `analyzers/dotnet/roslyn*` because its package also targets older runtimes. `EnforceExtendedAnalyzerRules` is on, so the banned-API rules (RS1035) apply. - **The code fix is an assembly of its own.** It needs `Microsoft.CodeAnalysis.Workspaces`, which the command-line compiler does not have, and a generator or analyzer must not reference it (RS1038): the extension would fail to load there. The compiler never loads a type of the code fix assembly, so the assembly beside the generator is harmless to a build, and the IDE uses it. - **The generator** asks for the classes with `ForAttributeWithMetadataName`, which the compiler answers from an index of attribute names, and describes each one as a string: no symbol, compilation or location enters the pipeline. One `Registration` value, which compares by value (`EquatableArray`), comes out of `Collect()`, so an edit that leaves the registered classes as they were does not write the file again. `IncrementalityTests` check that with `trackIncrementalGeneratorSteps`: every step's outputs are `Cached` or `Unchanged` after an unrelated edit. - **Output.** A static class in the namespace `Inngest`, named after the assembly, with a hash of the name added when an identifier cannot hold all of it: two assemblies of one app each get a class, and a public class of the same name in both would hide the extension methods of the one that is referenced (the compiler only warns, CS0436), which `My-Lib` and `My.Lib` would do without the hash. One statement for each class, because a long chain of calls is one expression and the compiler has a limit on those. The file disables CS0612 and CS0618, because a function class may be `[Obsolete]` and the warning would be in a file nobody can edit. The method is `internal` unless the project says `public`, and its name is a project property too. With public methods of the same name in two assemblies, or an internal one seen through `InternalsVisibleTo`, the call is ambiguous (CS0121); `InngestFunctionsMethodName` is the way out. - **Diagnostics.** The generator reports nothing about classes. `FunctionClassification` answers whether a class can be registered, for the generator, which leaves out the classes that cannot, and for `FunctionClassAnalyzer`, which says why, when the project has turned generation on. The two cannot disagree, and the messages are analyzer diagnostics, which `#pragma` and `.editorconfig` control. A property value the generator cannot use is reported by the generator. - **JSON analyzer.** It registers nothing unless `PublishAot`, `PublishTrimmed` or `JsonSerializerIsReflectionEnabledByDefault` (read through `CompilerVisibleProperty`) say that reflection-based JSON is off; the SDK only flips the switch while publishing, so the properties it flips it from are read, and they are those of the project it compiles, so a library that sets none is not checked. It checks the type arguments of the function base class, `AddFunction(options, handler)` and the typed steps (`RunAsync`, `AIWrapAsync`, `WaitForEventAsync`, `WaitForSignalAsync`, `InvokeAsync`, `InferAsync` and `PublishAsync`, which read their type back through the app's serializer options). - **What counts as listed** is what the generator of System.Text.Json writes metadata for, and the analyzer follows its rules: the types of public properties and fields, and of non-public ones with `[JsonInclude]`, minus `[JsonIgnore]`; the parameters of the one constructor it calls (`[JsonConstructor]`, else the only public one when there is no public parameterless one); the element type of a collection, whose own members and constructors it ignores; the type arguments of framework generics; `[JsonDerivedType]`. A type with a `[JsonConverter]` has nothing reached through it. The roots are the types a context lists, in the project and in referenced assemblies that use System.Text.Json and are not part of the framework, plus the types `PublicTypesJsonContext` lists, the context the SDK appends to every app's options, so that `string` or `IReadOnlyList` is not asked for when the app's context reaches none. `JsonCoverageTests` hold the analyzer to this: they compile a set of probe types, one for each rule, and compare the analyzer with the generator of every .NET version they can find in the targeting packs (8 and 10 agree), and compare the types taken from the SDK's context with the ones that context has. The set is computed when the first type needs it. - **The generator cannot fill the context.** Generators run on the same input and the output of one is not input to another, so `[JsonSerializable]` attributes written by Inngest's generator would never reach System.Text.Json's. The code fix edits the user's own file instead, with `ImportAdder` and the simplifier for the using directives. Its equivalence key names the context and not the type, and its own `FixAllProvider` adds every type of the scope to that context in one edit: the batch fixer merges text changes, which collide when several diagnostics edit the same class. - **The package path is tested.** Everything above runs from the build output in the unit tests, which is not how a user gets it. `PackagedConsumerTests` pack `Inngest` and `Inngest.AspNetCore` into a temporary feed, restore them into a small app that references only `Inngest.AspNetCore`, and build it in each state: nothing set (no diagnostic, under warnings as errors), generation on, reflection off, and `DisableInngestAnalyzers`. A CI job runs them with the .NET 8 SDK too. ### Logging `ILogger` with source-generated `LoggerMessage` methods throughout. `FunctionContext.Logger` drops messages while the function replays memoized steps, so a message written between two steps appears once per run rather than once per call. ### Webhooks `MapInngestWebhook` turns a provider's webhooks into events. The work is split so that nothing but the last step knows about ASP.NET Core: ``` Kestrel -> MapInngestWebhook -> WebhookEndpoint (bounded, pooled body) -> WebhookReceiver size check -> IWebhookVerifier.Verify -> JsonDocument.Parse -> WebhookProvider.CreateEvent -> WebhookOptions.Transform -> IInngestClient.SendAsync (within SendTimeout) -> status code ``` - **Package.** `Inngest.Webhooks` holds the verifiers, `WebhookProvider`, `WebhookOptions` and `WebhookReceiver`, and depends on `Inngest` alone. `Inngest.AspNetCore` references it and adds the endpoint, as `InngestCommHandler` in `Inngest` is reached by `MapInngest` and by the Connect worker. The verifiers work on `IWebhookRequest` (the raw bytes and a header lookup), so an Azure Functions or a Lambda host can reuse them. `WebhookReceiver` is internal for now; such a host gets `InternalsVisibleTo` or the type becomes public when it needs it. `Inngest` itself gained no API. - **Verification** is in front of everything: the body is not parsed, and no header is used for anything but the signature's own inputs, until a signature matches. Signatures are decoded without exceptions, compared with `CryptographicOperations.FixedTimeEquals` against every candidate and every secret (no early exit), and the timestamp window is checked after the signature, so the log can tell a replay from a forgery. A secret never appears in a message, and neither does a body, a signature or a header. - **Schemes.** Standard Webhooks signs `{id}.{timestamp}.{body}` with HMAC-SHA256 and the base64 key behind `whsec_`, and carries several `v1,` signatures for rotation; `v1a` (Ed25519) is ignored, because .NET has no Ed25519 in its libraries. Stripe signs `{t}.{body}` with the text of the secret and carries one `v1` per active secret. GitHub and Shopify sign the body only, so they have no replay window and their headers are not covered. `HmacWebhookVerifier` expresses all of them, and Slack's `v0:{timestamp}:{body}`, as options; GitHub and Shopify are two presets of it. - **Events.** The default mappings follow Inngest's own transforms where its guide has one (`stripe/` with the event as data and its `id` as the id; `/` with the `data` of the payload for Clerk and Resend). The data is a `JsonElement` of the payload, which the client writes through the serializer options with `JsonElement` metadata that `PublicTypesJsonContext` always adds, so it needs no reflection, and nothing from a header is put in it: only the body is signed by GitHub and Shopify, so their event names and ids, which come from headers, are checked for their shape (short visible ASCII, the whole name at most 256 characters) but remain whatever a holder of one captured delivery sends. A provider that has delivery ids needs one: a delivery without is a 400, since an event without an id would never be deduplicated. - **Lifetime.** The parsed payload reads the pooled body in place. Before the send, a `JsonElement` as the event's `Data` or `User` is cloned, so that the event can be kept (the test host's `SentEvents` and middleware do), and the pooled body is cleared and returned when the request ends. - **Answers** have no body: 200, 204 (the transform dropped it), 401, 400, 413, 503 (Inngest did not accept the event within `SendTimeout`, which is short because providers wait seconds, or failed in a way that may pass) and 500 (a verifier or a transform threw). A send that may pass is a 5xx on purpose: a provider that retries delivers the webhook again and the delivery id makes a second attempt a duplicate that Inngest drops. A send that cannot pass is not: an `InngestException` with a 4xx other than 408, 425 and 429 (the statuses the client itself retries) is answered 413 for 413 and 400 otherwise, a client refusal (`ArgumentException`) is a 400, and a payload that parses but cannot be written (an unpaired surrogate escape) is found by writing it once before the send and is a 400; each is logged as an error that says a redelivery fails the same way. ### What is supported | Area | Status | | - | - | | Serve endpoint: introspection, in-band and out-of-band sync, calls | Supported | | Dev mode and cloud mode, signing, signing key rotation, response signing | Supported | | Steps: run, sleep, sleepUntil, waitForEvent, invoke, sendEvent, parallel steps | Supported | | A dependency injection scope per step handler, for a `DbContext` and other scoped services | Supported (.NET only): `RunScopedAsync` and typed overloads on the step tools | | Flow control and function config: concurrency, throttle, rate limit, debounce, batching, priority, idempotency, retries, cancelOn, timeouts, singleton | Supported | | Function config as attributes on the class, typed events, typed results and invoke by class | Supported (.NET only) | | Settings from a configuration section (`appsettings.json`), below `INNGEST_*` variables | Supported (.NET only) | | Failure handlers (`inngest/function.failed`) | Supported | | Errors: non-retriable, retry-after, step failures | Supported | | Middleware: observer and wrap hooks, for the whole app or for one function | Supported | | `use_api` for large runs | Supported | | Checkpointing (running several steps per call and saving each to Inngest as it completes) | Supported, opt-in with `CheckpointingOptions`; off by default | | Connect (WebSocket workers): start, handshake, heartbeats, leases, reply buffering and flush, draining, reconnects, graceful shutdown | Supported (`Inngest.Connect`) | | Connect connection state and a readiness health check | Supported (`IInngestConnection`, `AddInngestConnect`) | | Serve health check: keys configured, the API answers a request that carries the signing key, optionally the app is synced | Supported (`AddInngest` on `IHealthChecksBuilder`, `InngestHealthCheckOptions`) | | Aspire AppHost: the dev server and a self-hosted server as containers, apps synced at the address the server's container reaches, `INNGEST_*` settings for projects and containers | Supported (`Inngest.Aspire.Hosting`), for local development. The servers keep no state outside their container. | | `step.waitForSignal` and sending signals, `step.fetch`, `step.ai.infer` | Supported (`WaitForSignalAsync`, `SendSignalAsync`, `FetchAsync` and `AddInngestStepFetch`, `InferAsync`) | | `step.ai.wrap` | Supported (`AIWrapAsync`), with its input shown in the Inngest UI and an input edited there passed to the handler; an input edited for `step.ai.infer` is spread over its body, as in inngest-js | | Realtime: publishing data and streams, subscription tokens, subscribing | Supported: `PublishAsync` and `PublishStreamAsync` (`text/stream`) on the step tools and `IInngestRealtime`, typed topics, tokens, `SubscribeAsync` over Inngest's server-sent events with a new token on every reconnect, and `MapInngestRealtime`, a relay to browsers that shares one subscription per channel and topics | | Realtime messages to SignalR clients: joining and leaving through a hub, authorization of each join, one shared subscription per channel and topics, a bounded queue for each connection, delivery by connection id | Supported (`Inngest.SignalR`) over the JSON and MessagePack protocols; under Native AOT only where SignalR supports it (JSON, .NET 9 and later) | | Blazor components for realtime: Blazor Server, WebAssembly and interactive Auto | Supported (`Inngest.Blazor`): `RealtimeView` subscribes after its first interactive render and never while prerendering, through a source each side registers; the WebAssembly source reads the server-sent events of `MapInngestRealtime` with `HttpClient` response streaming and opens the connection again with a growing delay. It does not interpret run messages or pause in hidden tabs. | | AWS Lambda: the serve endpoint behind API Gateway REST and HTTP APIs and function URLs, in a managed runtime function or a Native AOT executable | Supported (`Inngest.AwsLambda`, `InngestLambdaHandler`), with buffered responses only: `Streaming` has no effect, and the call's cancellation token is never cancelled | | Streaming responses (`INNGEST_STREAMING`) | Supported on the ASP.NET Core endpoint (`InngestOptions.Streaming`): a call longer than three seconds gets a 201, a space every three seconds and the wrapped result. Azure Functions streams the same way with the worker's ASP.NET Core integration, and not with its built-in HTTP model. | | Azure Functions (isolated worker): built-in HTTP model and ASP.NET Core integration | Supported (`Inngest.AzureFunctions`): tested against fakes of the worker and against the Inngest server behind a stand-in for the Functions host, not in a deployed app. A call has to be answered within the platform's 230 seconds. | | Receiving webhooks: Stripe, GitHub, Shopify, Standard Webhooks (Svix, Clerk, Resend) and HMAC signatures, event ids from the delivery ids | Supported (`Inngest.Webhooks`, `MapInngestWebhook`). Inngest's hosted webhooks and their transforms are the counterpart. `v1a` (Ed25519) signatures are not checked. | | Event sessions and their propagation (`meta.sessions`, `sessionPropagation`) | Supported (`EventMeta`, `FunctionContext.Sessions`, `InngestOptions.SessionPropagation`) | | The user behind an event: selected claims of the current user in the event's `user`, protected, restored in the run, passed on to events, invocations and failure handlers | Supported (.NET only): `Inngest.UserContext`, `Inngest.UserContext.AspNetCore`. See [User context](https://damikun.github.io/inngest-dotnet/internals/design.md#user-context) | | `defer` | Not yet | | Durable endpoints (beta in Inngest) | Supported on ASP.NET Core and marked experimental (`INNGEST001`): `UseInngestDurableEndpoints`, `WithDurableEndpoint` and `[DurableEndpoint]` in `Inngest.AspNetCore`. The steps of a request run inline, and the run goes to Inngest through the checkpoint API when a step has to wait or fails, or steps run in parallel. Not yet: the output proxy on the app's domain, streaming responses from an endpoint, resuming an endpoint that requires authorization, and `InngestTestHost`. Flow control, `defer` and `group.experiment` are not supported for endpoints upstream | | Middleware transform hooks (`transformFunctionInput`, `transformStepInput`) | Supported as `IPayloadTransform`, on raw JSON: events, memoized step results, step output and function output. The data an invoke sends is not transformed. | | `wrapRequest` | Not implemented; ASP.NET Core middleware or an endpoint filter on `MapInngest` wraps a request | | `group.parallel` race mode | Supported (`ParallelAsync(ParallelMode.Race, ...)`, or `StepOptions.ParallelMode` for one step) | | Fan-out over a list: a step per item with stable ids, a window of parallel items, fail-fast or collected outcomes, chunks in child runs for lists beyond the 1,000 steps of a run | Supported (.NET only): `MapAsync`, `ForEachAsync`, `MapOutcomesAsync`, `MapInChunksAsync` and `MapOutcomesInChunksAsync` over `IStepTools`, and `FanOutFunction` for the function of a chunk | | REST API for runs: get a run, list the runs of an event, cancel a run | Supported (`IInngestRunsClient`) | | OpenTelemetry spans and metrics | Supported (`Inngest.OpenTelemetry`) | | Encryption middleware (`@inngest/middleware-encryption`): default strategy, fallback keys, decrypt-only mode, the event field, custom services | Supported (`Inngest.Encryption`), readable by the TypeScript package and the reverse. It reads the AES envelopes of the package's first versions and never writes them, and it does not encrypt the data of `step.invoke`, which has no payload hook, nor the input of AI wrap steps and the request of inference steps, which no payload hook covers. | | Sentry error reporting | Supported (`Inngest.Sentry`), without the TypeScript middleware's Sentry spans | | Chat clients of Microsoft.Extensions.AI as steps | Supported (.NET only, `Inngest.Extensions.AI`): `UseInngestSteps` makes model calls and streaming calls steps, `UseInngestFunctionInvocation` makes tool calls steps, with the input shown in the Inngest UI and an edited input passed on. Responses are saved as JSON written with `AIJsonUtilities.DefaultOptions`, so the app registers no contracts for them, and `RawRepresentation` does not survive a replay. A streaming call publishes its text to a realtime topic while the model runs, when asked to. `InngestInferChatClient` has Inngest call an OpenAI-compatible provider instead (`step.ai.infer`). | | Transactional outbox: events saved with the app's data in one transaction and sent afterwards under one id each | Supported (`Inngest.EntityFrameworkCore`) on Entity Framework Core 8 (net8.0) and 10 (net10.0), not 9; the tests run SQLite on both and PostgreSQL with EF Core 10 and Npgsql 10 on net10.0 only; not Native AOT | | Unit tests on the real execution engine, with mocked steps | Supported (`Inngest.Testing`) | | End-to-end tests against the dev server or a self-hosted server in Docker, including the Connect gateway, and against an app under `WebApplicationFactory` | Supported (`Inngest.Testing.Containers`, `Inngest.Testing.Containers.AspNetCore`), with the image you choose; tested with `inngest/inngest:v1.49.0`. The factory needs .NET 10. | | `dotnet new` templates for a web app and a Connect worker, and a tool that runs the Inngest dev server | Supported (`Inngest.Templates`, `dotnet-inngest`) | | Registering function classes without listing them | Supported (.NET only), when a project sets `InngestGenerateFunctionRegistration`: the source generator in `Inngest` writes `AddInngestFunctions()`. It does not compute the options at compile time | | Finding the types a Native AOT app has not given its `JsonSerializerContext` | Supported (.NET only): an analyzer and a code fix in `Inngest`, for function classes, delegate functions and the typed steps; values whose type is known only at run time, such as the data of an invoke or an event the app sends, are not checked | ## Spec deviations | Topic | Spec | This SDK, following the server and inngest-js | | - | - | - | | Step not found | `StepNotFound` op | `500` with `X-Inngest-No-Retry: false`; the server has no such op | | Bad signature | `500` | `401` | | Sleep and wait payloads | `opts.duration`, `opts.event` | sent both in `opts` and in `name`, which the server falls back to | | Sync kind values | `in-band` | `in_band` / `out_of_band` | | Default retries | "4 attempts" | the server's default of 4 retries (5 attempts) | | `sdk` field | equal to `X-Inngest-SDK` | `dotnet:v` in the body, `inngest-dotnet:v` in the header, as the server only splits the body value on `:` | ## Differences from the TypeScript SDK Behaviour that differs on purpose, beyond the API shape: | Area | TypeScript SDK | This SDK | | - | - | - | | Function options | Invalid options produce a warning at sync | Rejected at startup with one exception that lists every problem, each naming its function | | Sync URL | Keeps `deployId` in the registered URLs | Strips it, as the spec requires | | Failure handlers | Always registered with runtime type `http` | Same runtime type as the function they belong to | | Event sends | Retries every failure | Retries transport errors, 5xx, 408, 425 and 429; other 4xx fail at once | | `INNGEST_DEV` URL | Redirects the APIs even when `isDev` is false | Only used in dev mode | | Flags in variables | `1`/`true` and `0`/`false`; any other `INNGEST_DEV` is a dev server address | Also `on`/`yes` and `off`/`no`, so `INNGEST_DEV=off` is cloud mode, not a dev server at `http://off` that turns off signature checks | | Error names | JavaScript error names, with a `__serialized` marker | .NET exception type names, no marker | | Planned run steps | `opts` is `{}` | `opts.type` is `"step.run"` | | Targeted call whose step was started but not awaited | Answers 200 and drops the step | Runs the step | | Uncaught `StepFailedException` | Final only for the most recent step failure | Always final | | Memoized `{}` | Read as an error | Read as data | | Steps called inside other steps or observer hooks | Hang | Throw `InvalidOperationException` | | Invalid in-band sync body | 500 | 400 `invalid_request` | | Introspection | Omits `has_signing_key_fallback` and `authentication_succeeded` when unauthenticated | Includes both, as the spec describes | | In-band `sdk_author` | `inngest` | `inngest-dotnet` | | `Match` and `If` on a wait or cancellation | Match replaces If (the types forbid setting both) | Both apply, joined with `&&` | | Connect shutdown | Closes the socket without a code | Closes with 1000 and `WORKER_SHUTDOWN`, which the gateway recognizes | | Connect replies not acknowledged | Flushed over HTTP only on reconnect and close | Flushed as soon as the 5 second deadline passes, and at shutdown | | Unsigned out-of-band syncs | Can be refused with `INNGEST_ENABLE_UNAUTHED_SYNC=false` | Same, plus `AllowUnsignedSync`; unsigned callers are never told why a sync failed | ## AWS Lambda `InngestLambdaHandler` serves the endpoint from a Lambda function. It reduces the two payload formats of API Gateway (`APIGatewayProxyRequest` for REST APIs, `APIGatewayHttpApiV2ProxyRequest` for HTTP APIs and function URLs) to an `IServeRequest`, hands it to `InngestCommHandler` with no service scope, so that the handler creates one per invocation, and maps the `ServeResponse` back to the response type of the payload format. The framework name is `aws-lambda`, as in inngest-js. - **Bodies.** The signature covers the bytes Inngest sent: the UTF-8 of the body text, or what a base64 body decodes to (`isBase64Encoded`, which API Gateway sets when a binary media type matches). Responses are JSON, so they leave as UTF-8 text with `isBase64Encoded` false, and their signature, made over those bytes, holds. - **Headers.** Names are matched without regard to case. REST events merge `headers` and `multiValueHeaders`, with the multi-value form winning; HTTP API events have lowercase names and values joined by commas. - **Query.** HTTP APIs decode `queryStringParameters` twice, so a `%2B` arrives as a space. `rawQueryString` is the source for HTTP APIs, and REST events, which only have decoded dictionaries, are encoded into a query string. One parser reads both, so a parameter has the same value from either. - **URL.** The scheme comes from the last value of `X-Forwarded-Proto`, which the closest proxy appends and ASP.NET Core's forwarded headers middleware trusts (`https` unless it says `http`), and the host from `Host`, or the request context's domain name. The path is `rawPath` for HTTP APIs, which already starts with a named stage, and `requestContext.path` for REST APIs when it extends `path`, which is when it holds the stage. API Gateway fills it with the path the client called; on a custom domain both fields hold the base path mapping, as a real event in aws-lambda-dotnet issue 127 shows. inngest-js uses `path`, which leaves the stage out. The URL is built on first use, and only out-of-band syncs use it: an in-band sync names the URL in its signed body. Anything the event cannot show, such as the API mapping of an HTTP API or a CloudFront host, is left to `ServeOrigin` and `ServePath`. - **Time.** The call's cancellation token is `CancellationToken.None`. Lambda gives no signal before it ends an invocation, and cancelling shortly before `RemainingTime` runs out could only turn the 5xx that Inngest retries anyway into an error of the handler's own: the serve handler rethrows a cancellation, so an error response would have to be built outside it, where the headers and the signature are made, calls that would have finished lose their last moments, and the abandoned work stays alive in an environment that a timeout would have reset. Checkpointing's `MaxRuntime` limits the time steps may start within. - **Streaming.** The answer is a single buffered response, which cannot write the 201 and the spaces of `InngestOptions.Streaming` while a call runs, so `Streaming` is not consulted, and the handler logs a warning (event id 380) when it is built with the setting on. Lambda can stream responses to .NET functions (`Amazon.Lambda.Core.ResponseStreaming` with function URLs in `RESPONSE_STREAM` mode, or REST APIs with a streaming integration), which would be a second response path with its own prelude; the handler does not use it. - **Native AOT.** Nothing reflects, and CI publishes `samples/Inngest.LambdaAotSample`, the guide's function, where a warning fails the publish. The event types come from `Amazon.Lambda.APIGatewayEvents`, which the app lists in its own `JsonSerializerContext` for `SourceGeneratorLambdaJsonSerializer`. The package ships none: a context fixes the shape of types it does not own, cannot hold the REST and HTTP API event types together (their nested types share names, `SYSLIB1031`) and could not be combined with the app's other event types. ## Connect With `AddConnect`, the app opens an outbound WebSocket to Inngest instead of exposing an endpoint, which suits workers behind a firewall. The worker calls `POST {api}/v0/connect/start` with the hashed signing key, dials the returned gateway with the `v0.connect.inngest.com` subprotocol and exchanges protobuf `ConnectMessage` frames (`connect.v1`, copied from inngest-js): 1. Handshake: `GATEWAY_HELLO`, then `WORKER_CONNECT` with the same app configuration a sync sends (runtime type `ws`), then `GATEWAY_CONNECTION_READY` with the heartbeat and lease intervals, then `WORKER_READY`. 2. Each `GATEWAY_EXECUTOR_REQUEST` is acknowledged, run through the same execution engine as an HTTP call, kept alive with `WORKER_REQUEST_EXTEND_LEASE` and answered with `WORKER_REPLY`. A reply that is not acknowledged within 5 seconds is sent again over `POST {api}/v0/connect/flush`. 3. `GATEWAY_CLOSING` makes the worker open a new connection before closing the old one; a lost connection is retried with backoff (1 s up to 5 minutes); a 401 switches to the fallback signing key. 4. On shutdown the worker sends `WORKER_PAUSE`, waits for running calls up to the host's shutdown timeout and closes with 1000 `WORKER_SHUTDOWN`. Each socket has a single writer fed by a channel and a reader that never runs user code, so a slow function cannot stall heartbeats or lease extensions. ## Health check `AddInngest` on `IHealthChecksBuilder` (`Inngest.AspNetCore`) tells a readiness probe whether an app that serves functions can work with Inngest; `AddInngestConnect` does the same for a Connect worker. It lives in `Inngest.AspNetCore` because only apps that call `MapInngest` use it and that package already references ASP.NET Core, where the health checks are, so `Inngest` gets no new dependency. It sends its requests through `InngestApiTransport`, reached by `InternalsVisibleTo`, so they carry the SDK headers and the hashed signing key and are repeated with the fallback key on 401 or 403, like every call to the API. `SendKeyedAsync` also says which key was answered, which the result shows. **The request.** In cloud mode, and against a self-hosted server, `GET {api}/v2/apps?limit=1`: it needs the key, lists at most one app and changes nothing. `GET /v2/health` would be lighter, but Inngest Cloud answers it to anyone (an unauthenticated request gets a 200), so it would pass with a key Inngest has never seen. A self-hosted server does check the key there. In dev mode, `GET {dev server}/health`, the server's own health endpoint, which does not depend on the v2 REST API: `/v2/apps` came with Inngest v1.40.0 and `/v2/apps/{id}` with v1.27.1. A 2xx is healthy, 401 and 403 are a refused key, 429 is Degraded and anything else is a failure. The body of that request is not read, and no error text of Inngest's goes into a description, so a gateway's error page cannot reach one. **Sync.** With `CheckSync`, after the first request succeeded, `GET {api}/v2/apps/{app id}`, which Inngest marks Beta: `data.functionCount`, `data.isArchived` and `data.latestSync` (`status`, `syncedAt`, `error`, `appVersion`). The dev server leaves out zero and false values and reports no status or time. The order of the verdicts: 404 is not registered; archived; a status of `error`, or an error without a status; another app version than `AppVersion`, when both are set; another number of functions than the registry holds; else synced. The registry's count is the number of definitions, which includes failure handlers, as Inngest counts them. The status, time and version that reach the result are Inngest's own text for the app, cut to 64 characters. Nothing is kept in the process about syncs: Inngest syncs through whichever instance the load balancer picks. **Cost.** The answer is kept per registration: `CacheDuration` for a healthy or rate limited one, and the smaller of `FailureCacheDuration` and `CacheDuration` for a failure, so that an instance is not unready for long after a blip. Probes wait on one gate, and each takes the answer when it gets through, so overlapping probes send one request. The health check service builds the check for every run, in a scope of its own, so the answer lives in the registration, not in the check. **Cancellation.** The request ends with the probe's token and at 90 percent of the registration's timeout, so that the check can say Inngest was too slow with the registration's failure status before the health check service gives up on it with its own words and `Unhealthy`. A registration has no timeout unless it is given one, so then the request ends after 10 seconds; HttpClient would wait for 100. A cancelled probe stores nothing, and a timeout is stored as a failure. One deadline covers both requests of a check. **What the result holds.** `signingKeyFallback`, `signingKey` and `eventKey` are `set`, `not set` or `missing`, never values. The address is shown as scheme, host and port. The tests look for the keys, the hashed token and the event key in the description, the data and the log of every kind of answer. ## Test containers `Inngest.Testing.Containers` follows the module pattern of testcontainers-dotnet 4.16: `InngestServerBuilder` derives from `ContainerBuilder`, `InngestServerConfiguration` from `ContainerConfiguration`, `InngestServerContainer` from `DockerContainer`, and the builder takes the image, as every module has since 4.10, so there is no default image to go stale. The types are named after the server because `InngestBuilder` is one letter from the SDK's `IInngestBuilder`, which test code uses beside them. The builder keeps what the caller chose (the mode, the keys, the app URLs, the gateway) in the configuration and turns it into the command line in `Build`, so the order of its methods never matters and one builder can build several containers, each with keys, a gateway port and a connection string provider of its own: a provider remembers the container it was started with, so a shared one answers for the last. Arguments added with `WithCommand` come last, and a wait strategy with conditions of its own, which `WithWaitStrategy` replaces the default with, is kept. - **Command.** `inngest dev --no-discovery --no-poll`, or `inngest start --signing-key K --event-key E`, then the timing flags (`--poll-interval 1 --retry-interval 1 --tick 50`), `--connect-gateway-port` and a `-u` for each app. The `inngest start` signing key must be plain hexadecimal digits in pairs. With `-u` the server retries a failed sync at the poll interval and stops once it succeeds; it does not poll the app afterwards, and nothing tells the test when the app is registered. - **Reaching the host.** Inside the container localhost is the container, so a loopback app URL becomes `host.docker.internal`, which the builder maps to `host-gateway` for Linux. Any other address is left as it is. The app must listen on all interfaces, since on Linux the container comes in over the Docker bridge, not loopback. - **Ports.** The API is published on a random host port. The Connect gateway is published on the same port inside and outside, because the start API hands workers the port as the container knows it, so the port has to be chosen before the container exists. A port the builder picks is held by a listening socket from `Build` until the container starts, because pulling the image can take long and the port may be taken in the meantime. The container lets it go just before Docker starts the container, or when it is disposed. Starting again with a fresh port when Docker still finds the port taken is not possible on the same container, as `DockerContainer` keeps its configuration to itself and a created container cannot be created again; the caller builds another. The server binds its gRPC ports (50052 and 50053\) and the gateway only after it has opened connections, and the kernel may give such a port to one of them as a source port, so the container reserves them with `net.ipv4.ip_local_reserved_ports`. - **Syncing after the start.** A `PUT` to the app's endpoint makes an SDK register itself with the server, under the URL the request arrived on. `SyncAsync` sends it with the container's name for the app as the `Host` header, so neither the app nor the server needs the other's address before it starts and no port is picked up front. It also sends `X-Inngest-Server-Kind` (`dev` or `cloud`), which the SDK hands to the server it registers with, as with the server's own sync. For the self-hosted server the request carries `X-Inngest-Signature`, a MAC of the (empty) body and the timestamp, which keeps the app from logging an unsigned sync. The GraphQL `createApp` mutation of the dev UI also has the server sync an app, and was not used: against `inngest start` the sync it makes arrives unsigned, a failed one stays in the app list, and the error is a bare code such as `unreachable` or `url_not_found`. - **Variables.** `GetSdkEnvironment` is for processes that inherit their parent's environment, so it sets every variable that could outrank the others. The SDK reads the API address from `INNGEST_API_BASE_URL`, then `INNGEST_BASE_URL`, then the dev server's address, the event API address from `INNGEST_EVENT_API_BASE_URL` first, and `INNGEST_DEV` picks the mode, where only an explicit `0` keeps an inherited `INNGEST_DEV=1` from switching the signature checks off. Variables the list leaves out, such as `INNGEST_SERVE_ORIGIN`, still apply, and the guide says so. - **WebApplicationFactory.** The server needs a real socket, which `TestServer` does not have. .NET 10 added `UseKestrel`; earlier versions can only get one with a second host built beside the in-memory one, which runs hosted services twice and splits the app's state, so `InngestWebApplicationFactory` targets net10.0 and lives in a package of its own: `Microsoft.AspNetCore.Mvc.Testing` brings build targets and a version that must not be forced on users of the container module. The factory calls `UseKestrel` with `ListenAnyIP(0)`. It has to be called on the factory that starts, because a factory made by `WithWebHostBuilder` does not inherit it. The options are applied with `PostConfigure` from `CreateHost`, so the container wins whatever the app or an override of `ConfigureWebHost` set. Kestrel reports an app on all interfaces as `[::]`, which the factory takes for its clients' base address and which a client cannot connect to on Windows. `CreateDefaultClient` is fixed in `ConfigureClient`, but `CreateClient` and `CreateDefaultClient(Uri, ...)` set the base address after it, from options that default to `http://localhost`, port 80, so the factory declares `CreateClient` again. The addresses collection of a started Kestrel cannot be changed, so there is no earlier place to fix it. ## Templates and the dotnet-inngest tool **The template package.** `src/Inngest.Templates` has `PackageType` Template and no build output. Each template under `templates/` is a real project with `!!INNGEST_VERSION!!` where the version of an Inngest package goes. At pack time the target `StageTemplates`, which runs before the package files are collected and so also for `dotnet pack --no-build`, copies the folder to `obj`, replaces the tokens with `$(PackageVersion)` in a small inline MSBuild task and packs the copy. It fails when a token is left, and it copies on every pack, because a new version changes the result while the files stay the same. The worker's `Microsoft.Extensions.Hosting` takes its version from `Directory.Packages.props` the same way. This follows the Aspire templates, which replace `!!REPLACE_WITH_LATEST_VERSION!!` in a copy at pack time. The ASP.NET Core templates generate their project files with a task of the Arcade SDK, which a project outside that repository does not have, and Aspire runs a C# script with `dotnet`. An inline MSBuild task needs neither. The `template.json` files use what `dotnet new` offers instead of tokens of their own: a `Framework` choice that replaces `net10.0`, the `port` generator with `coalesce` for `--http-port`, the `kebabCase` form of the project name for the app id, a fresh `UserSecretsId`, and the restore post action. The generated code has no namespace and no folders, as in the getting started guide. `Microsoft.TemplateEngine.Authoring.Tasks` only localizes templates, and `Microsoft.TemplateEngine.Authoring.TemplateVerifier` snapshots a template folder, so neither is used. **Their tests.** `tests/Inngest.Templates.Tests` packs `Inngest`, `Inngest.AspNetCore`, `Inngest.Connect` and `Inngest.Templates` into a temporary feed with a version of its own (a version restored from a feed stays in the global packages folder, so a fixed one would hide changes), installs the template package into a throwaway hive (`--debug:custom-hive`), makes both templates and builds them with a `nuget.config` that maps `Inngest*` to the feed and the rest to nuget.org. It also starts the web app and reads its introspection, which proves the launch profile, the Development settings and dev mode work together. The commands run in a folder outside the repository, where the newest SDK builds everything, so the net8.0 worker gets a `global.json` that selects the .NET 8 SDK; the test is skipped, with a reason, on a machine that has none, and CI installs both. That build turns off `CheckEolTargetFramework`, since warnings are errors in it and the SDK warns about a target framework out of support. A run packs four projects and builds two, so it is a job of its own in CI. Each of the two packages has a README of its own, `src/Inngest.Templates/README.md` and `src/dotnet-inngest/README.md`, packed in place of the shared `src/README.md`, which describes the SDK. The templates project also leaves `bin` and `obj` out of what it packs, for a template that was built in place. **The tool.** `src/dotnet-inngest` is a `PackAsTool` project named `dotnet-inngest`, so that `dotnet inngest` finds it, as `dotnet ef` finds `dotnet-ef`. It targets `net8.0` and `net10.0`, each in its own folder of the package, with `RollForward=Major` for a machine that has neither. The arguments are parsed by hand: the tool owns three options and passes the rest to `inngest dev`, whose options change with the release, which is the opposite of what a command line library is for, and the tool stays free of dependencies. Its types are internal, tested through `InternalsVisibleTo`. - **Download.** The asset is named `inngest___.` in the release of the tag, as the install script and the npm package expect it, chosen by the architecture of the machine. The pinned version is checked against `PinnedChecksums`, a copy of the release's `checksums.txt` in the source, and never against the release, so a file replaced on GitHub together with its checksums is caught. Another version is checked against its `checksums.txt`, which catches damage only. The pin, the `inngest/inngest` image in the docs and the integration tests are raised together. - **Cache.** `/inngest-dotnet/cli//_/inngest`. The download and the unpacked binary are made in a staging folder beside it, which is moved into place in one step, so an interrupted install leaves nothing that looks installed and two installs at once end with one. Only the binary is unpacked, to a path the tool chooses, whatever the archive's entries are called, and it gets the execute bit. It is flushed to disk before the rename, so a crash cannot leave a file without content that the next run finds. The checks happen at install time only, and a binary in the cache is run as it is, so the folder must not be writable by other users; the docs say so. - **Running.** The CLI inherits the console, so its output and input are the user's. Ctrl+C reaches the CLI as it does any foreground program, and the tool waits for it and returns its exit code. On Unix `SIGINT` and `SIGTERM` that reach the tool alone are passed on with `kill`; the CLI takes the second `SIGINT` of a terminal as one. `SIGKILL` and a crash cannot be passed on, and on Windows nothing is. There is no job object, which could not be tested, so a tool that is ended alone leaves the CLI running. - **Downloads.** A download is limited by how long the server may send nothing, 60 seconds for the headers and between two reads of the body, and not by its total time. With `ResponseHeadersRead` the `Timeout` of an `HttpClient` ends at the headers, so it would leave a body that stalls waiting forever. The tool sets it to infinite and limits the reads itself. A limit that fires, which the user did not ask for, is an error with the Docker command. Only Ctrl+C ends with 130. - **Address.** Without `-u` or `--no-discovery`, the first launch profile that runs the project and has an `http` address gives the app's URL, plus `/api/inngest`. - **Docker.** The failures that mean the CLI cannot be had print the `docker run` command for the same address. Nothing is run for the user. ## Transactional outbox `Inngest.EntityFrameworkCore` makes sending an event part of a database transaction. The app adds events to its `DbContext` with `AddInngestEvent`, `SaveChanges` stores them with its data, and a hosted dispatcher per context type sends them afterwards. Delivery is at least once. What is meant to make a resend harmless is that every attempt carries the row's event id, which Inngest is documented to drop when it repeats within 24 hours; see Event ids below for what was checked. - **Table.** `InngestOutboxMessage`, mapped by `ModelBuilder.AddInngestOutbox()` as `InngestOutbox`. The key is the event id, a ULID unless the code gives one, so a second row for the same fact fails the save. The id, the name and the version are limited to 255 characters, and a case-insensitive collation (the default of SQL Server and MySQL) makes ids that differ only in case the same key. `Data`, `User` and `Sessions` are the event's JSON as text, written when the event is added; times are UTC `DateTime`s with a converter that makes the kind UTC, because providers return them unspecified. `Timestamp` is the event's `ts`, which the caller may set to any time, so `CreatedAt` records when the row was added. One index on (`SentAt`, `NextAttemptAt`) serves the claim and the cleanup. - **Adding.** `AddInngestEvent` makes the checks the client makes when it sends (name, data and user as JSON objects, sessions) and serializes through the `JsonTypeInfo` of `InngestSettings.SerializerOptions`, so a failure is found while the code that added the event is on the stack and trimmed apps use the metadata they already registered for `SendAsync`. It finds the writer in the application service provider that `AddDbContext`, `AddDbContextPool` and `AddDbContextFactory` leave in the context's options. The event keeps the time it was added as its `ts`, not the time the dispatcher sends it. `PropagatedSessions` is refused, because it only exists for events sent from a run, and an event added inside a function does not inherit the run's sessions, because the dispatcher sends it outside the run. - **Waking.** One interceptor instance serves every context and needs no service provider at configuration time: it finds the signal through the application services of the context that saved, so `UseInngestOutbox()` works in the callbacks that have no provider and in `OnConfiguring`. When the dispatcher starts it says once if the context's options lack the interceptor. `AddInngestEvent` marks the context; `SavedChanges` wakes the dispatcher at once when no transaction of the code's own is open, and otherwise leaves the wake-up to `TransactionCommitted`, so the dispatcher is not woken to find nothing. A rollback clears the mark. The signal is a one-slot channel per context type, so a wake-up that comes while the dispatcher is busy is kept, and many count as one. With a `TransactionScope` EF sees no transaction of its own, so the wake comes at the save, before the scope completes, and the events leave at the next poll (checked on PostgreSQL; SQLite has no ambient transactions). The poll interval is the fallback for everything else, including saves by other processes. - **Claiming.** Dispatchers take no lock. A claim selects the ids of the due rows (`SentAt` null, `NextAttemptAt` passed, no live lease), then `ExecuteUpdate`s those ids with a token and an expiry, repeating the test for "due" in the `WHERE` of the update, and reads back the rows that hold the token, by key as well, because the token has no index and the table holds a day of sent rows. The two statements matter: on PostgreSQL, an `UPDATE ... WHERE id IN (SELECT ... LIMIT n)` that waited for a row another one was updating took the row as well and overwrote the token (checked with two sessions), where the repeated test in the outer `WHERE` made the loser update nothing. A dispatcher that died holds its rows until the lease ends. Marking sent or failed names the token, so a dispatcher that lost its lease changes nothing. - **Sending.** One request per claimed batch, each event with its own `id`, through `IInngestClient`, so the client's retries and the middleware apply. A request Inngest refuses because of its events (400, 409, 412, 413, 422, or an event the client refuses to write) is repeated event by event, so one bad event does not hold back the others; other failures fail the batch together. The first of those requests is sent whatever is left of the lease, and the others only while the lease covers another request, so every pass decides at least one event; the options require a lease of at least twice `SendTimeout` for the same reason. A failed event is retried after `RetryDelay`, doubling per failure up to `MaxRetryDelay`, and is never given up; each refused one is logged at error level on every attempt, with its id and name. When the host stops, claimed events are released without an attempt. - **Clocks.** Leases, retries and the due test compare an instance's clock with times other instances wrote, because the clock is the `TimeProvider`'s and not the database's. A dispatcher takes rows another is still sending only if its clock is ahead by more than `LeaseDuration` minus `SendTimeout`; a row saved by an instance whose clock is ahead waits for the others' clocks, unless that instance dispatches it itself. - **Cleanup.** Sent rows older than `Retention` (a day, as long as Inngest remembers ids) are deleted with `ExecuteDelete` in batches, on a loop of its own so that a long cleanup never delays a send. - **SQL Server.** Under the locking `READ COMMITTED` that is its on-premises default, the due query waits for rows that open transactions inserted, so one long unit of work that adds an event stalls every dispatcher until it commits; the remedy is `READ_COMMITTED_SNAPSHOT`, on by default in Azure SQL Database. This follows from how SQL Server locks and was not run. PostgreSQL does not wait (a test holds an uncommitted event while a dispatcher runs). - **Event ids.** Inngest takes any string as an event id. The dev server (v1.49.0) starts a function's run once per id: a second send under the same id is answered 200 with a new internal event id, and no run starts. The server's source, read at the v1.49.0 tag (28cb533), says that a repeated event can exist in its event store, because the id is enforced when scheduling runs, and keeps the run's idempotency key for 24 hours (`FunctionIdempotencyPeriod`), which is what Inngest's documentation gives for Inngest Cloud. Only a repeat within seconds was tested, and only against the dev server. How the established outboxes differ, where it shaped this one (read at CAP ad19c50, MassTransit d9f61f7 and Wolverine e9110b7, all of October 2026). CAP stores messages in the app's transaction through its own transaction wrapper and, after the commit, pushes them into an in-process queue; its retry processor polls for failed rows and serializes instances with a lock row in a table that expires (`UseStorageLock`), and its cleanup deletes expired rows in batches of 1000. MassTransit locks one outbox state row per unit of work with provider-specific statements (`FOR UPDATE SKIP LOCKED`, `UPDLOCK, READPAST`), sends in sequence order inside that transaction and deletes the rows it sent; its commit signal is a cancelled delay when the scope ends after the entity left the `Added` state. Wolverine stores envelopes with an owner node id, sends from memory after the commit, and lets a durability agent reassign the rows of nodes that left the cluster. This package takes no cluster membership and no provider-specific SQL: a lease column set by `ExecuteUpdate` is the only coordination, which suits Inngest because it does not order events and drops a repeated id. **EF Core versions.** `ExecuteUpdate` and `ExecuteDelete` are in `Microsoft.EntityFrameworkCore.Relational` in EF Core 8 and in `Microsoft.EntityFrameworkCore` from EF Core 9 on, so code compiled against one does not run on the other (a `MissingMethodException`). The net8.0 build uses EF Core 8 and the net10.0 build EF Core 10, selected in `Directory.Packages.props`; an app on .NET 9 gets the net8.0 build, so EF Core 9 is not supported, and the dispatcher refuses to start when it finds EF Core 9 loaded, naming the reason. There is no net9.0 target, because the SDK has none. EF Core is not trim or Native AOT compatible (its `DbContext` constructors are marked `RequiresUnreferencedCode` and `RequiresDynamicCode`), so the project sets `IsAotCompatible` to false while keeping the analyzers on; its own code has no warning. **Tests.** `tests/Inngest.EntityFrameworkCore.Tests` runs against SQLite on both runtimes (the net8.0 build on EF Core 8, in a container when the machine has no .NET 8 runtime). `tests/Inngest.IntegrationTests/Outbox` runs a dispatcher against PostgreSQL with Testcontainers, on net10.0 only, so with EF Core 10 and Npgsql 10: four dispatchers at once, the order of claims that would overwrite a token if the update did not repeat the test for "due", commit visibility and a `TransactionScope`. Against the dev server, an event saved through the outbox starts its function, and a resend under the same id starts nothing. ## Aspire `Inngest.Aspire.Hosting` puts the Inngest server into an Aspire AppHost. The shape follows the container integrations of Aspire (`Seq`, `RabbitMQ`) and of the Community Toolkit (`MailPit`, `Ollama`): a `ContainerResource`, an `Add*` extension, an internal class that holds the image tag, health checks and a link for the UI. These decisions are specific to Inngest: - **The container.** The image is `inngest/inngest`, at the tag the integration tests of this repository use, and has no entrypoint, so the resource sets `inngest` as the entrypoint. The dev server runs `dev --no-discovery`: discovery scans ports inside the container, where the apps are not. Polling stays on, at the server's default of 5 seconds, so that an app that starts after the server, or restarts with other functions, is found; the self-hosted server polls only with `--poll-interval`, so it gets one. - **Addresses.** Nothing is written as text. `WithApp` and `WithReference` put `ReferenceExpression`s over endpoint references into the arguments (`-u
`, once per app) and the environment, and Aspire resolves them for the resource that receives them. For a server's container, an app that is a project on the host is `aspire.dev.internal` with the port of the container tunnel (or `host.docker.internal` where the tunnel is off); for a project on the host the server is `localhost` with the proxy's port; for a container it is the name on the container network. The SDK registers the address it was synced through, so the step calls go to the same address. - **The Connect gateway.** `POST /v0/connect/start` answers a worker with the host it called and the port the gateway listens on in the container (8289), which is not the port Aspire publishes it on, so a worker that used the answer would log `Could not open a WebSocket to the gateway at ws://localhost:8289/v0/connect`. Publishing the gateway on the same port is not an option: Aspire picks only the host port of a container endpoint and wants the container's port in the model. So the gateway is an ordinary proxied endpoint, and `WithReference` gives the worker the address of that endpoint as `INNGEST_CONNECT_GATEWAY_URL`, which the Connect worker prefers to the server's answer (`ConnectOptions.GatewayUrl`). A worker that is started outside `WithReference` gets the server's answer. The tests start a Connect worker against both servers. - **Dev and self-hosted.** The dev server's reference is `INNGEST_DEV=
`, as `samples/Inngest.Sample/compose.yaml` has it for dev mode. The self-hosted server's reference is `INNGEST_DEV=0` and the two keys, as the `cloud` profile of that file has it; `0` is set because projects inherit the environment of the AppHost, and an inherited `INNGEST_DEV=1` would turn off signature checks. Both also get the address in `INNGEST_BASE_URL`, `INNGEST_API_BASE_URL` and `INNGEST_EVENT_API_BASE_URL`: `InngestSettings` reads the last two before the base URL, so an inherited one would win over it. Other `INNGEST_*` variables of the AppHost's environment still reach the projects, and an `INNGEST_SERVE_ORIGIN` makes runs undeliverable without a word, which the guide says. The keys reach the server through `INNGEST_SIGNING_KEY` and `INNGEST_EVENT_KEY`, which its CLI reads (flag, then variable, then file), so they are not in the command line. `inngest start` takes a signing key only as hex of even length, so the generated one is 64 digits, and a key that is not is reported when the server starts. - **The manifest.** The dev server is excluded (`ExcludeFromManifest`), and `WithReference` to it sets nothing in publish mode: a reference would dangle, and `INNGEST_DEV` would put a deployed app into dev mode, which checks no signatures. The self-hosted server is a `container.v0` with its keys as parameters, and what refers to it has settings for cloud mode. `ManifestTests` writes the manifest through the publish mode of an AppHost and checks that every `{resource.…}` expression names a resource in it. - **Tests.** `tests/Inngest.Aspire.Hosting.Tests` is itself an AppHost project: `Aspire.Hosting.Testing` finds the orchestrator and the dashboard through assembly attributes that `Aspire.AppHost.Sdk` writes, and has no other way to find them. The `AppModel` tests resolve arguments and environments for endpoints that the test allocates. `PinnedVersionsTests` fails when the Aspire version of `global.json` (the AppHost SDK) differs from the packages in `Directory.Packages.props`, or when the default image differs from the one that the harness of the integration tests runs. The `Containers` tests run both servers in Docker next to a project and a Connect worker, through the real orchestrator, with an inherited `INNGEST_*` environment that the settings of `WithReference` must win over. ## User context `Inngest.UserContext` carries the user who caused a run into it. It is a format and a middleware; `Inngest` itself only lets it see its internals. It has no counterpart in the TypeScript SDK. - **Wire.** The event's `user` object, which Inngest documents as user data and stores encrypted at rest, gets one reserved key, `_inngest_dotnet_user: {"v":1,"t":""}`; every other key is the app's. `t` is what `IUserContextProtector.Protect` made of this JSON: `{"v":1,"iat":,"exp":,"ev":"","nt":..,"rt":..,"c":{"":[""]}}`. `ev` ties the text to the event it was made for, and `fn` for an invocation to the function that is invoked (`-`), because `inngest/function.invoked` is one name for every function and Inngest builds it from a body its caller supplies. `nt` and `rt` are the name and role claim types when they are not the .NET defaults, and `c` holds only the claim types of `UserContextOptions.Claims`, up to `MaxSize` bytes. The expiry and the event name are inside the protected text, so every protector has them and a custom one only has to detect changes and, ideally, encrypt. The default protector is Data Protection with the purposes `Inngest.UserContext` and `v1`. - **Capture.** `WrapSendEventAsync` runs for every send. Outside a run it asks the `IUserContextSource`; the ASP.NET Core package registers one over `IHttpContextAccessor`, which flows with the async context into the scope the client makes for middleware. Inside a run (`context`, or `Invocation.AmbientContext` for a client used in a function) it attaches the run's own snapshot and never asks the source, because the request that reached the app is Inngest's. Each event gets a text made for its own name, and the snapshot keeps its original `iat` and `exp`, so passing it on never extends it. An event or invocation whose run would start after `exp` gets none, and an information line says so. Invocations have no middleware payload hook, so `InvokeOptions.WithUser` puts the envelope in `InvokeOptions.User`. - **Restore.** `WrapFunctionAsync` reads `FunctionContext.RawEvent`, or for the failure handler of a function (`FunctionDefinition.FailureHandlerFor`) the event inside `inngest/function.failed`, and keeps the snapshot in a `ConditionalWeakTable` keyed by the run's `FunctionContext`. `ICurrentUser` is a singleton that finds the running run through `Invocation.AmbientContext` on every read, so it is the same in every DI scope of the call, including the sibling scope a scoped step makes, and in singletons. A function that batches events gets no user: a batch has no single one. - **Validity.** A function replays from its top on every call, so a user that expired halfway through a run would change under it. The expiry is compared with the later of the creation time of the run, which its id holds (a ULID that Inngest makes and every call shares), and the `ts` of its event (in the failed run's event for a failure handler), not with the clock of the call. Against the dev server, the run of an event with a future timestamp is created when the event is received but starts at the timestamp, so the creation time alone would let a payload copied from an event act as its user years after it expired. A run held back by a queue after it was created keeps its user. An id that is not a ULID falls back to the clock of the call, with a warning. - **Failure.** A payload that cannot be used gives an anonymous principal and one warning, written once per run through the replay-safe logger, that names the reason and the event and never the payload. `OnInvalid = FailRun` fails the run instead, except a failure handler: without retries, except for text that could not be unprotected, which is retried. Data Protection lets the `CryptographicException` of a payload that is not valid through as it is and wraps every other failure, such as a key store that cannot be read, in one with an `InnerException`. The default protector therefore throws for the second kind, so that the call fails and is retried instead of running as nobody, and answers false for the first.